//! Try-Not-to-Laugh Challenge (ActivityElation, panel 50064): seven //! battles with a fixed trial team plus the player's picks. //! Client side (REA, 4.6.53 ActivityElationModule): //! //! - GetElationActivityDataScRsp lists one ElationActivityLevelData per //! played level; the client marks it via ActivityElationStage.SetLevelResult. //! - EnterElationActivityStageCsReq names the level and both avatar lists; //! the reply carries the battle the client starts at once. //! - ElationActivityBattleEndScNotify, after PVEBattleResult, carries the //! new result. //! //! Levels come from resources/ActivityElationStage.json (the client's //! ActivityElationStage table: EventID = stage, BattleTargetList, PerfectWave) //! and the stage's waves from StageConfig.json. Results persist in //! elation.json beside the other save files. const std = @import("std"); const protocol = @import("protocol"); const Session = @import("../Session.zig"); const Packet = @import("../Packet.zig"); const BattleManager = @import("../manager/battle_mgr.zig"); const ConfigManager = @import("../manager/config_mgr.zig"); const Allocator = std.mem.Allocator; const CmdID = protocol.CmdID; const log = std.log.scoped(.elation); const table_path = "resources/ActivityElationStage.json"; /// The server's world level (battle_mgr.createBattleInfo). const world_level: u32 = 6; const save_path = "elation.json"; const Level = struct { id: u32, event_id: u32, perfect_wave: u32, monsters: []const u32, targets: []const u32, modified_avatars: []const u32, selectable_cast: bool, }; /// rounds = RemainingRounds, waves = FinishedRounds, mask = finished targets. const Result = struct { id: u32, perfect: bool, rounds: u32, waves: u32, mask: u32 }; var arena_state: ?std.heap.ArenaAllocator = null; var levels: []const Level = &.{}; var results: std.ArrayListUnmanaged(Result) = .{}; /// The level whose battle is running, until PVEBattleResult. var active: ?u32 = null; fn u32List(a: Allocator, v: ?std.json.Value) ![]const u32 { const list = v orelse return &.{}; if (list != .array) return &.{}; var out = std.ArrayList(u32).init(a); for (list.array.items) |item| if (item == .integer and item.integer > 0) try out.append(@intCast(item.integer)); return out.items; } fn int(v: ?std.json.Value) u32 { const x = v orelse return 0; return if (x == .integer and x.integer > 0) @intCast(x.integer) else 0; } /// Loads the level table and the saved results once. fn load() void { if (arena_state != null) return; arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); const a = arena_state.?.allocator(); if (std.fs.cwd().readFileAlloc(a, table_path, 4 << 20)) |text| { if (std.json.parseFromSliceLeaky(std.json.Value, a, text, .{})) |root| { if (root == .array) { var out = std.ArrayList(Level).init(a); for (root.array.items) |row| { if (row != .object) continue; const id = int(row.object.get("ID")); if (id == 0) continue; out.append(.{ .id = id, .event_id = int(row.object.get("EventID")), .perfect_wave = int(row.object.get("PerfectWave")), .monsters = u32List(a, row.object.get("MonsterList")) catch &.{}, .targets = u32List(a, row.object.get("BattleTargetList")) catch &.{}, .modified_avatars = u32List(a, row.object.get("ModifiedAvatarIDList")) catch &.{}, .selectable_cast = int(row.object.get("IsModifiedAvatarFixed")) == 1, }) catch {}; } levels = out.items; } } else |err| log.warn("{s}: {s}", .{ table_path, @errorName(err) }); } else |err| log.warn("{s} not loaded ({s}); Exploratory Excursion has no levels", .{ table_path, @errorName(err) }); if (std.fs.cwd().readFileAlloc(a, save_path, 1 << 20)) |text| { if (std.json.parseFromSliceLeaky([]const Result, a, text, .{ .ignore_unknown_fields = true })) |saved| { results.appendSlice(std.heap.page_allocator, saved) catch {}; } else |_| {} } else |_| {} } fn level(id: u32) ?Level { for (levels) |l| if (l.id == id) return l; return null; } fn save() void { var buf = std.ArrayList(u8).init(std.heap.page_allocator); defer buf.deinit(); std.json.stringify(results.items, .{ .whitespace = .indent_2 }, buf.writer()) catch return; const tmp = save_path ++ ".tmp"; std.fs.cwd().writeFile(.{ .sub_path = tmp, .data = buf.items }) catch return; std.fs.cwd().rename(tmp, save_path) catch {}; } /// The wire form of a result, mapped from the client's WriteTo tags and /// ActivityElationStage.SetLevelResult (4.6.53): field 2 = finished-target /// bit mask, 14 = FinishedRounds, 13 = RemainingRounds, 6 = is_perfect, /// 7 = level_id. The stage's MaxRounds is its PerfectWave. fn levelData(r: Result) protocol.ElationActivityLevelData { return .{ .level_id = r.id, .is_perfect = r.perfect, .FFGFINDLMCF = r.mask, .CHPCJIBGKFK = r.waves, .BOEINFJIILE = r.rounds, }; } /// SpecialAvatar WorldLevel=6 rows, OSBETAWin4.6.53. The client resolves /// CharacterID/kit from this ID and world_level, not from ModifiedAvatarID. fn trialBattleAvatar(a: Allocator, id: u32, index: u32) !protocol.BattleAvatar { const rank: u32 = switch (id) { 3231202, 3231224, 3121201, 3121108, 3121301 => 3, 3238007, 3238008 => 6, 3231309, 3321217, 3231015, 3231403, 3121306, 3121220, 3121304, 3121412, 3321414 => 0, else => return error.InvalidElationTrial, }; var avatar = protocol.BattleAvatar.init(a); avatar.id = id; avatar.index = index; avatar.avatar_type = .AVATAR_TRIAL_TYPE; avatar.world_level = world_level; avatar.level = 80; avatar.promotion = 6; avatar.rank = rank; avatar.hp = 10000; avatar.sp_bar = .{ .cur_sp = 5000, .max_sp = 10000 }; return avatar; } /// ElationBattleModifiedAvatar.ID -> SpecialAvatarID, OSBETAWin4.6.53. /// These trials load the Elation character JSON; ordinary trials do not. fn comedianTrial(id: u32) ?u32 { return switch (id) { 1 => 3121306, 2 => 3121201, 3 => 3121220, 4 => 3121304, 5 => 3121108, 6 => 3121412, else => null, }; } /// Field 4 is native Cast; field 2 is Supporting. Do not infer Cast from /// supporting picks or replace zero IDs: the client caches the request before /// entry and uses those same IDs to find the Comedian kits during battle. /// In 4.6.53, Comedians have SpecialAvatar.Type=6; GetTrialInstanceID calls /// CheckIsTrial (types 1/4 only), so it returns 0 for them. Activity data has no /// unlock/identity fields that can repair this client-side conversion. fn validateCast(lv: Level, supporting: []const protocol.ElationActivityAvatar, cast: []const protocol.ElationActivityAvatar) !void { if (cast.len == 0 or cast.len + supporting.len > 4 or lv.modified_avatars.len == 0) return error.InvalidElationCast; if (!lv.selectable_cast and cast.len != lv.modified_avatars.len) return error.InvalidElationCast; for (cast, 0..) |pick, slot| { if (pick.avatar_type != .AVATAR_TRIAL_TYPE or pick.avatar_id == 0) return error.InvalidElationCast; for (cast[0..slot]) |previous| { if (previous.avatar_id == pick.avatar_id) return error.InvalidElationCast; } if (lv.selectable_cast) { // The final stage permits any nonempty subset of its six kits. for (lv.modified_avatars) |id| { if (pick.avatar_id == (comedianTrial(id) orelse return error.InvalidElationCast)) break; } else return error.InvalidElationCast; } else if (pick.avatar_id != (comedianTrial(lv.modified_avatars[slot]) orelse return error.InvalidElationCast)) { return error.InvalidElationCast; } } } /// Cast occupies the leading battle slots, followed by Supporting Cast. /// Preserve order within each list; wire field numbers are not slot order. fn appendRoster(a: Allocator, battle: *protocol.SceneBattleInfo, supporting: []const protocol.ElationActivityAvatar, cast: []const protocol.ElationActivityAvatar) !void { if (cast.len + supporting.len == 0 or cast.len + supporting.len > 4) return error.InvalidElationTeam; for ([_][]const protocol.ElationActivityAvatar{ cast, supporting }) |list| { for (list) |pick| { if (pick.avatar_id == 0) return error.InvalidElationTeam; for (battle.battle_avatar_list.items) |previous| { if (previous.id == pick.avatar_id) return error.InvalidElationTeam; } var avatar = switch (pick.avatar_type) { .AVATAR_TRIAL_TYPE => try trialBattleAvatar(a, pick.avatar_id, @intCast(battle.battle_avatar_list.items.len)), .AVATAR_FORMAL_TYPE => (try BattleManager.ownedBattleAvatar(a, pick.avatar_id)) orelse return error.InvalidElationTeam, else => return error.InvalidElationTeam, }; avatar.index = @intCast(battle.battle_avatar_list.items.len); try battle.battle_avatar_list.append(avatar); } } } pub fn onGetElationActivityData(session: *Session, _: *const Packet, allocator: Allocator) !void { load(); var rsp = protocol.GetElationActivityDataScRsp.init(allocator); rsp.retcode = 0; for (results.items) |r| try rsp.level_data_list.append(levelData(r)); log.info("activity data: levels={d} saved_results={d}", .{ levels.len, results.items.len }); try session.send(CmdID.CmdGetElationActivityDataScRsp, rsp); } pub fn onEnterElationActivityStage(session: *Session, packet: *const Packet, allocator: Allocator) !void { load(); const req = try packet.getProto(protocol.EnterElationActivityStageCsReq, allocator); defer req.deinit(); log.info("enter level={d} body={}", .{ req.level_id, std.fmt.fmtSliceHexLower(packet.body) }); for (req.fixed_avatar_list.items) |av| log.info("enter level={d} supporting avatar type={s} id={d}", .{ req.level_id, @tagName(av.avatar_type), av.avatar_id }); for (req.player_selected_avatar_list.items) |av| log.info("enter level={d} Cast avatar type={s} id={d}", .{ req.level_id, @tagName(av.avatar_type), av.avatar_id }); const lv = level(req.level_id) orelse { try session.send(CmdID.CmdEnterElationActivityStageScRsp, protocol.EnterElationActivityStageScRsp{ .retcode = 1, .level_id = req.level_id }); return; }; // The client sends the stage's default Comedians as trial ID 0 (its // GetTrialInstanceID only resolves SpecialAvatar types 1/4, Comedians are // type 6); fill those slots from the level's ModifiedAvatar list. for (req.player_selected_avatar_list.items, 0..) |*pick, slot| { if (pick.avatar_id != 0 or pick.avatar_type != .AVATAR_TRIAL_TYPE or lv.selectable_cast) continue; if (slot >= lv.modified_avatars.len) break; pick.avatar_id = comedianTrial(lv.modified_avatars[slot]) orelse 0; log.info("level {d}: Cast slot {d} filled with Comedian trial {d}", .{ lv.id, slot, pick.avatar_id }); } validateCast(lv, req.fixed_avatar_list.items, req.player_selected_avatar_list.items) catch { log.warn("level {d}: missing native Comedian Cast; expected ModifiedAvatar IDs {any} (SpecialAvatar trials), got field2={d} field4={d}", .{ lv.id, lv.modified_avatars, req.fixed_avatar_list.items.len, req.player_selected_avatar_list.items.len }); try session.send(CmdID.CmdEnterElationActivityStageScRsp, protocol.EnterElationActivityStageScRsp{ .retcode = 1, .level_id = lv.id }); return; }; var waves = std.ArrayList(std.ArrayList(u32)).init(allocator); defer { for (waves.items) |w| w.deinit(); waves.deinit(); } // EventID is a PlaneEvent: its stage is EventID * 10 + world level // (4270016 for 427001 at world level 6). That stage holds one spawner // monster; the level's script brings in MonsterList itself. var monster_level: u32 = 80; var stage_id: u32 = lv.event_id; find: for ([_]u32{ lv.event_id * 10 + world_level, lv.event_id * 10, lv.event_id }) |candidate| { for (ConfigManager.global_game_config_cache.stage_config.stage_config.items) |stage| { if (stage.stage_id != candidate) continue; stage_id = candidate; monster_level = stage.level; for (stage.monster_list.items) |wave| try waves.append(try wave.clone()); break :find; } } if (waves.items.len == 0) { // No StageConfig row: the level's own monster list as one wave. var wave = std.ArrayList(u32).init(allocator); try wave.appendSlice(lv.monsters); try waves.append(wave); log.warn("stage {d} is not in StageConfig.json; using the level's monster list", .{lv.event_id}); } // Keep One Take's shared builder unchanged. Elation needs ordered Cast // slots and world-level-aware trial headers instead of its bare trials. var battle = try BattleManager.createEventBattle(allocator, stage_id, waves, monster_level, &.{}, &.{}, lv.targets); defer battle.deinit(); // Native requests put Supporting in field 2 and Comedian Cast in field 4. appendRoster(allocator, &battle, req.fixed_avatar_list.items, req.player_selected_avatar_list.items) catch |err| switch (err) { error.InvalidElationTeam, error.InvalidElationTrial => { log.warn("level {d}: rejected Elation roster ({s})", .{ lv.id, @errorName(err) }); try session.send(CmdID.CmdEnterElationActivityStageScRsp, protocol.EnterElationActivityStageScRsp{ .retcode = 1, .level_id = lv.id }); return; }, else => return err, }; log.info("level {d}: stage={d} world={d} avatars={d}", .{ lv.id, stage_id, battle.world_level, battle.battle_avatar_list.items.len }); active = lv.id; @import("battle.zig").on_battle = true; try session.send(CmdID.CmdEnterElationActivityStageScRsp, protocol.EnterElationActivityStageScRsp{ .retcode = 0, .level_id = lv.id, .battle_info = battle, }); } /// Called after every PVEBattleResult; acts only on an Exploratory Excursion /// battle. A win counts every wave and target (the client does not report /// per-target progress for this mode), a loss records nothing. pub fn onBattleEnd(session: *Session, allocator: Allocator, req: *const protocol.PVEBattleResultCsReq) !void { _ = allocator; const id = active orelse return; active = null; const lv = level(id) orelse return; if (req.end_status != .BATTLE_END_WIN) return; // A win clears every wave (FinishedRounds = MaxRounds = PerfectWave). // The battle reports no spare-round count for this mode, so the // remaining rounds stay 0. const mask: u32 = if (lv.targets.len >= 32) std.math.maxInt(u32) else (@as(u32, 1) << @intCast(lv.targets.len)) - 1; const result = Result{ .id = id, .perfect = true, .rounds = 0, .waves = lv.perfect_wave, .mask = mask }; for (results.items) |*old| { if (old.id != id) continue; old.* = result; break; } else results.append(std.heap.page_allocator, result) catch {}; save(); try session.send(CmdID.CmdElationActivityBattleEndScNotify, protocol.ElationActivityBattleEndScNotify{ .level_id = id, .level_data = levelData(result), // Field 11 is compared with MaxRounds for IsPerfectFinished; 6 and // 12 feed the result screen's counters. .perfect_wave = lv.perfect_wave, .JCFMCEPKEHA = 0, .JIGFPIOBAHB = mask, }); } const entrance = @import("elation_entrance.zig"); const dessert_entrance = @import("comic_dessert_entrance.zig"); const mech_entrance = @import("mech_craft_entrance.zig"); fn entranceEnabled(allocator: Allocator) bool { // Development checkouts omit PROFILE-BUILD. Their installed Elation table // explicitly opts in; a packaged runtime with another identity is excluded. if (std.fs.cwd().readFileAlloc(allocator, "PROFILE-BUILD", 128)) |version| { defer allocator.free(version); if (!entrance.supports(version)) return false; } else |err| { if (err != error.FileNotFound) return false; } load(); return levels.len == 7; } /// Keep CW's existing scene wrapper (and all other destinations) intact. pub fn onEnterScene(session: *Session, packet: *const Packet, allocator: Allocator) !void { const req = try packet.getProto(protocol.EnterSceneCsReq, allocator); defer req.deinit(); const floor = if (req.scene_identifier) |identifier| identifier.floor_id else 0; if (dessert_entrance.matches(req.entry_id, floor) and dessert_entrance.enabled(allocator)) return dessert_entrance.enterScene(session, req, allocator); if (mech_entrance.matches(req.entry_id, floor) and mech_entrance.enabled(allocator)) return mech_entrance.enterScene(session, req, allocator); if (!entrance.matches(req.entry_id, floor) or !entranceEnabled(allocator)) return @import("grid_fight.zig").onEnterScene(session, packet, allocator); var manager = @import("../manager/scene_mgr.zig").SceneManager.init(allocator, session.player_uid); // Arrive beside the terminal (prop 300001 at 100.505, 49.094, -18.545), // facing it, instead of at anchor 2032101 about 28 m away. var scene = try manager.createSceneAt(20321, floor, req.entry_id, .{ .x = 103005, .y = 48402, .z = -18545 }, .{ .y = 270000 }, 0); try entrance.append(&scene); var lineup_manager = @import("../manager/lineup_mgr.zig").LineupManager.init(allocator); const lineup = try lineup_manager.createLineup(); try session.send(CmdID.CmdEnterSceneScRsp, protocol.EnterSceneScRsp{ .scene_identifier = req.scene_identifier, .is_over_map = true, .is_close_map = req.is_close_map, }); try session.send(CmdID.CmdEnterSceneByServerScNotify, protocol.EnterSceneByServerScNotify{ .lineup = lineup, .reason = .ENTER_SCENE_REASON_NONE, .scene = scene, }); log.info("native entrance loaded: floor={d} group={d} prop=300001", .{ floor, entrance.group_id }); } /// Only the Elation terminal's native group may acknowledge a state change. /// Other groups retain their previous ignored behavior. pub fn onGroupStateChange(session: *Session, packet: *const Packet, allocator: Allocator) !void { const req = try packet.getProto(protocol.GroupStateChangeCsReq, allocator); defer req.deinit(); const info = req.group_state_info orelse return; const control = @import("../control.zig"); if (dessert_entrance.matches(control.scene_entry, control.scene_floor) and info.group_id == dessert_entrance.group_id and dessert_entrance.enabled(allocator)) return session.send(CmdID.CmdGroupStateChangeScRsp, protocol.GroupStateChangeScRsp{ .group_state_info = info }); if (!entrance.matches(control.scene_entry, control.scene_floor) or !entrance.matches(info.entry_id, control.scene_floor) or info.group_id != entrance.group_id or !entranceEnabled(allocator)) return; try session.send(CmdID.CmdGroupStateChangeScRsp, protocol.GroupStateChangeScRsp{ .group_state_info = info }); } test "elation native Cast and supporting trial slots survive battle wire encoding" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); // Real split-list shape, using trial Supporting picks to avoid save data. const wire = [_]u8{ 0x22, 0x07, 0x20, 0x01, 0x28, 0x98, 0xc1, 0xbe, 0x01, 0x22, 0x07, 0x20, 0x01, 0x28, 0xc4, 0xc0, 0xbe, 0x01, 0x12, 0x07, 0x20, 0x01, 0x28, 0xf8, 0x9b, 0xc5, 0x01, 0x12, 0x07, 0x20, 0x01, 0x28, 0xcd, 0x9c, 0xc5, 0x01, 0x60, 0x04, }; const req = try protocol.EnterElationActivityStageCsReq.decode(&wire, a); var battle = protocol.SceneBattleInfo.init(a); battle.stage_id = 4270046; battle.world_level = world_level; battle.monster_wave_length = 1; var wave = protocol.SceneMonsterWave.init(a); wave.monster_param = .{ .level = 85 }; try wave.monster_list.append(.{ .monster_id = 800201004 }); try battle.monster_wave_list.append(wave); try std.testing.expectEqual(@as(usize, 2), req.player_selected_avatar_list.items.len); try validateCast(level_four_cast_test, req.fixed_avatar_list.items, req.player_selected_avatar_list.items); try appendRoster(a, &battle, req.fixed_avatar_list.items, req.player_selected_avatar_list.items); const bytes = try (protocol.EnterElationActivityStageScRsp{ .level_id = req.level_id, .battle_info = battle }).encode(a); const rsp = try protocol.EnterElationActivityStageScRsp.decode(bytes, a); const built = rsp.battle_info.?; try std.testing.expectEqual(@as(u32, 4270046), built.stage_id); try std.testing.expectEqual(@as(u32, 800201004), built.monster_wave_list.items[0].monster_list.items[0].monster_id); try std.testing.expectEqual(@as(usize, 4), built.battle_avatar_list.items.len); for (built.battle_avatar_list.items, [_]u32{ 3121304, 3121220, 3231224, 3231309 }, 0..) |av, id, i| { try std.testing.expectEqual(id, av.id); try std.testing.expectEqual(@as(u32, @intCast(i)), av.index); try std.testing.expectEqual(protocol.AvatarType.AVATAR_TRIAL_TYPE, av.avatar_type); try std.testing.expectEqual(world_level, av.world_level); try std.testing.expectEqual(@as(u32, 6), av.promotion); try std.testing.expectEqual(@as(u32, 10000), av.hp); try std.testing.expectEqual(@as(u32, 5000), av.sp_bar.?.cur_sp); try std.testing.expectEqual(@as(u32, 10000), av.sp_bar.?.max_sp); } try std.testing.expectEqual(@as(u32, 3), built.battle_avatar_list.items[2].rank); } test "elation rejects missing Cast duplicate slots and ModifiedAvatar metadata IDs" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const a = arena.allocator(); const cast = [_]protocol.ElationActivityAvatar{.{ .avatar_id = 3121220, .avatar_type = .AVATAR_TRIAL_TYPE }}; var battle = protocol.SceneBattleInfo.init(a); try std.testing.expectError(error.InvalidElationTeam, appendRoster(a, &battle, &.{}, &.{})); try std.testing.expectError(error.InvalidElationTeam, appendRoster(a, &battle, &cast, &cast)); try std.testing.expectError(error.InvalidElationTrial, trialBattleAvatar(a, 3, 0)); } const level_four_cast_test = Level{ .id = 4, .event_id = 427004, .perfect_wave = 6, .monsters = &.{ 2034012, 2033011 }, .targets = &.{}, .modified_avatars = &.{ 4, 3 }, .selectable_cast = false, }; test "elation rejects captured overwritten and zero-ID native Cast before battle creation" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); const bodies = [_][]const u8{ "1207200128f89bc5011207200128cd9cc5011205200328e10b1205200328de0b6004", "1205200328e10b1205200328de0b12052003289d0a1205200328e9076004", "1205200328e10b1205200328de0b22022001220220016004", }; for (bodies) |hex| { var buf: [64]u8 = undefined; const bytes = try std.fmt.hexToBytes(&buf, hex); const req = try protocol.EnterElationActivityStageCsReq.decode(bytes, arena.allocator()); try std.testing.expectEqual(@as(u32, 4), req.level_id); try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, req.fixed_avatar_list.items, req.player_selected_avatar_list.items)); } } test "elation native field 4 Cast precedes field 2 Supporting and preserves trial kits" { const cast = [_]protocol.ElationActivityAvatar{ .{ .avatar_id = 3121304, .avatar_type = .AVATAR_TRIAL_TYPE }, .{ .avatar_id = 3121220, .avatar_type = .AVATAR_TRIAL_TYPE }, }; const owned = [_]protocol.ElationActivityAvatar{ .{ .avatar_id = 1505, .avatar_type = .AVATAR_FORMAL_TYPE }, .{ .avatar_id = 1502, .avatar_type = .AVATAR_FORMAL_TYPE }, }; const trials = [_]protocol.ElationActivityAvatar{ .{ .avatar_id = 3231224, .avatar_type = .AVATAR_TRIAL_TYPE }, .{ .avatar_id = 3231309, .avatar_type = .AVATAR_TRIAL_TYPE }, }; try validateCast(level_four_cast_test, &owned, &cast); try validateCast(level_four_cast_test, &trials, &cast); try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, &(cast ++ owned), &.{})); try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, &cast, &owned)); try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, &owned, cast[0..1])); const reversed = [_]protocol.ElationActivityAvatar{ cast[1], cast[0] }; try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, &owned, &reversed)); var wrong_type = cast; wrong_type[0].avatar_type = .AVATAR_FORMAL_TYPE; try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, &owned, &wrong_type)); var plain = cast; plain[1].avatar_id = 1220; try std.testing.expectError(error.InvalidElationCast, validateCast(level_four_cast_test, &owned, &plain)); var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); var battle = protocol.SceneBattleInfo.init(arena.allocator()); try appendRoster(arena.allocator(), &battle, &trials, &cast); const encoded = try battle.encode(arena.allocator()); const decoded = try protocol.SceneBattleInfo.decode(encoded, arena.allocator()); try std.testing.expectEqual(@as(u32, 3121304), decoded.battle_avatar_list.items[0].id); try std.testing.expectEqual(@as(u32, 3121220), decoded.battle_avatar_list.items[1].id); try std.testing.expectEqual(@as(usize, 0), decoded.battle_event.items.len); } test "elation all stage Cast mappings and selectable final stage" { const ids = [_][]const u32{ &.{1}, &.{ 2, 1 }, &.{ 2, 4 }, &.{ 4, 3 }, &.{ 3, 6, 1 }, &.{ 5, 6, 1 }, &.{ 3, 1, 6, 4, 2, 5 } }; for (ids, 0..) |modified, index| { var lv = level_four_cast_test; lv.modified_avatars = modified; lv.selectable_cast = index == 6; var picks: [4]protocol.ElationActivityAvatar = undefined; const count = @min(modified.len, picks.len); for (modified[0..count], 0..) |id, i| picks[i] = .{ .avatar_type = .AVATAR_TRIAL_TYPE, .avatar_id = comedianTrial(id).? }; try validateCast(lv, &.{}, picks[0..count]); if (lv.selectable_cast) { try validateCast(lv, &.{}, picks[1..2]); const duplicate = [_]protocol.ElationActivityAvatar{ picks[0], picks[0] }; try std.testing.expectError(error.InvalidElationCast, validateCast(lv, &.{}, &duplicate)); try std.testing.expectError(error.InvalidElationCast, validateCast(lv, &.{}, &.{.{ .avatar_id = 1505, .avatar_type = .AVATAR_FORMAL_TYPE }})); } } }