Files
Currency-War-SR/integration/elation.zig
T

530 lines
27 KiB
Zig
Raw Normal View History

2026-10-30 23:54:07 +07:00
//! 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 }}));
}
}
}