From 01a17d968268463c66483e3d892599dfd00b270c Mon Sep 17 00:00:00 2001 From: VAGUE000 <45696542+VAGUE000@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:02:42 +0300 Subject: [PATCH 1/3] Implement Celica boss practice fights --- AscNet.Common/Database/Player.cs | 4 + AscNet.Common/MsgPack/Types.cs | 14 +- AscNet.GameServer/Handlers/AccountModule.cs | 27 ++ AscNet.GameServer/Handlers/FightModule.cs | 17 ++ .../Handlers/SimulateTrainModule.cs | 197 +++++++++++++ .../Program.SimulateTrainCompatibility.cs | 265 ++++++++++++++++++ AscNet.Test/Program.cs | 15 +- .../table/share/activity/ActivitySchedule.tsv | 1 + .../fuben/simulatetrain/SimulateTrainAtk.tsv | 7 + .../fuben/simulatetrain/SimulateTrainHp.tsv | 7 + .../simulatetrain/SimulateTrainMonster.tsv | 77 +++++ .../simulatetrain/SimulateTrainPeriodBuff.tsv | 23 ++ .../generate_current_simulate_train_tables.py | 221 +++++++++++++++ 13 files changed, 871 insertions(+), 4 deletions(-) create mode 100644 AscNet.GameServer/Handlers/SimulateTrainModule.cs create mode 100644 AscNet.Test/Program.SimulateTrainCompatibility.cs create mode 100644 Resources/table/share/fuben/simulatetrain/SimulateTrainAtk.tsv create mode 100644 Resources/table/share/fuben/simulatetrain/SimulateTrainHp.tsv create mode 100644 Resources/table/share/fuben/simulatetrain/SimulateTrainMonster.tsv create mode 100644 Resources/table/share/fuben/simulatetrain/SimulateTrainPeriodBuff.tsv create mode 100644 Scripts/generate_current_simulate_train_tables.py diff --git a/AscNet.Common/Database/Player.cs b/AscNet.Common/Database/Player.cs index fe5a9c46..b21649e1 100644 --- a/AscNet.Common/Database/Player.cs +++ b/AscNet.Common/Database/Player.cs @@ -644,6 +644,10 @@ public void SaveChecked() [BsonElement("unlock_comics")] public List UnlockComics { get; set; } = AscNet.Common.ArchiveDefaults.CreateDefaultUnlockedArchiveComics(); + [BsonElement("archive_monster_kills")] + [BsonDictionaryOptions(DictionaryRepresentation.ArrayOfDocuments)] + public Dictionary ArchiveMonsterKills { get; set; } = new(); + [BsonElement("life_tree_data")] public NotifyLifeTreeData LifeTreeData { get; set; } = new(); diff --git a/AscNet.Common/MsgPack/Types.cs b/AscNet.Common/MsgPack/Types.cs index 8c2933be..63754552 100644 --- a/AscNet.Common/MsgPack/Types.cs +++ b/AscNet.Common/MsgPack/Types.cs @@ -3078,6 +3078,17 @@ public class PreFightRequest [global::MessagePack.MessagePackObject(true)] public class PreFightRequestPreFightData { + [global::MessagePack.MessagePackObject(true)] + public class SimulateTrainInfoData + { + public Int32 BossId { get; set; } + public Int32 Period { get; set; } + public Int32 AtkLevel { get; set; } + public Int32 HpLevel { get; set; } + public Int32 Difficulty { get; set; } + public Int32 DangerCoefficient { get; set; } + } + public Int32 ChallengeCount { get; set; } public UInt32 StageId { get; set; } public Int32 ArenaSelectIndex { get; set; } @@ -3095,6 +3106,7 @@ public class PreFightRequestPreFightData public Int32 BossSingleStageType { get; set; } public dynamic? BossSingleChallengeBuffGroup { get; set; } public Int32? BossInshotTowerId { get; set; } + public SimulateTrainInfoData? SimulateTrainInfo { get; set; } } public PreFightRequestPreFightData PreFightData { get; set; } @@ -3120,7 +3132,7 @@ public class PreFightResponseFightData public Int32 FightCheckType { get; set; } public Int32 SegmentFightCheckSecond { get; set; } public Int32 StarsMark { get; set; } - public List MonsterLevel { get; set; } = new(); + public List? MonsterLevel { get; set; } = new(); public List EventIds { get; set; } = new(); public dynamic? FightEventsWithLevel { get; set; } public List NormalEventIds { get; set; } = new(); diff --git a/AscNet.GameServer/Handlers/AccountModule.cs b/AscNet.GameServer/Handlers/AccountModule.cs index 1d4eac7a..8cb67eec 100644 --- a/AscNet.GameServer/Handlers/AccountModule.cs +++ b/AscNet.GameServer/Handlers/AccountModule.cs @@ -15,6 +15,7 @@ using AscNet.Table.V2.share.fuben.bossinshot; using AscNet.Table.V2.share.fuben.fashionstory; using AscNet.Table.V2.share.fuben.transfinite; +using AscNet.Table.V2.share.fuben.simulatetrain; using AscNet.Table.V2.share.miniactivity.dyemerge; using AscNet.Table.V2.share.miniactivity.hitmouse; using AscNet.Table.V2.share.theatre6; @@ -1171,9 +1172,35 @@ private static NotifyArchiveLoginData BuildNotifyArchiveLoginData(Player player) List unlockComics = player.UnlockComics is { Count: > 0 } ? player.UnlockComics.Distinct().Order().ToList() : ArchiveDefaults.CreateDefaultUnlockedArchiveComics(); + List simulateTrainBosses = TableReaderV2.Parse() + .Where(row => row.NpcId.Count > 0) + .ToList(); + // A zero-count record unlocks the archive's main entry without claiming a kill. + Dictionary archiveMonsters = simulateTrainBosses + .Select(row => row.NpcId.First()) + .Distinct() + .ToDictionary(npcId => npcId, _ => 0); + foreach ((int npcId, int killed) in player.ArchiveMonsterKills ?? []) + { + if (npcId > 0 && killed > 0) + archiveMonsters[npcId] = killed; + } return new NotifyArchiveLoginData { + Monsters = archiveMonsters + .OrderBy(record => record.Key) + .Select(record => new NotifyArchiveLoginData.NotifyArchiveLoginDataMonster + { + Id = checked((uint)record.Key), + Killed = record.Value + }) + .ToList(), + MonsterUnlockIds = simulateTrainBosses + .Select(row => (uint)row.Id) + .Distinct() + .Order() + .ToList(), UnlockComics = unlockComics }; } diff --git a/AscNet.GameServer/Handlers/FightModule.cs b/AscNet.GameServer/Handlers/FightModule.cs index f874f884..f97ee76b 100644 --- a/AscNet.GameServer/Handlers/FightModule.cs +++ b/AscNet.GameServer/Handlers/FightModule.cs @@ -493,6 +493,14 @@ public static void PreFightRequestHandler(Session session, Packet.Request packet return; } + if (SimulateTrainModule.TryApplyPreFight(req.PreFightData, rsp.FightData, out int simulateTrainCode) + && simulateTrainCode != 0) + { + rsp.Code = simulateTrainCode; + session.SendResponse(rsp, packet.Id); + return; + } + if (TransfiniteModule.ApplyPreFight(session, req.PreFightData, out int transfiniteCode)) { rsp.Code = transfiniteCode; @@ -2512,6 +2520,10 @@ void AddRewardId(int? rewardId) TaskModule.RecordArenaResult(session, arenaResult.Point); } + NotifyArchiveMonsterRecord? simulateTrainArchiveRecord = SimulateTrainModule.RecordArchiveKill( + session.player, + session.fight?.PreFight.PreFightData, + req.Result); bool updatedRepeatChallenge = RepeatChallengeModule.RecordStageClear(session.player, req.Result.StageId, challengeCount); if (MainLineChapterIdsByStageId.Value.TryGetValue(responseStageId, out int mainLineChapterId)) { @@ -2551,11 +2563,16 @@ void AddRewardId(int? rewardId) MultiRewardGoodsList = multiRewards, ChallengeCount = isQuickClear ? 0 : challengeCount, ArenaResult = arenaResult, + SimulateTrainFightResult = SimulateTrainModule.BuildFightResult( + session.fight?.PreFight.PreFightData, + req.Result), } }; session.fight = null; session.SendPush(new NotifyStageData() { StageList = new() { stageData } }); + if (simulateTrainArchiveRecord is not null) + session.SendPush(simulateTrainArchiveRecord); StudyProgressModule.SendTeachingStageUpdate(session, stageData); bool sentTheatreProgress = BiancaTheatreModule.TrySendTheatreFightClearProgress(session, req.Result.StageId); if (!sentTheatreProgress) diff --git a/AscNet.GameServer/Handlers/SimulateTrainModule.cs b/AscNet.GameServer/Handlers/SimulateTrainModule.cs new file mode 100644 index 00000000..a7982b1f --- /dev/null +++ b/AscNet.GameServer/Handlers/SimulateTrainModule.cs @@ -0,0 +1,197 @@ +using AscNet.Common.Database; +using AscNet.Common.MsgPack; +using AscNet.GameServer.Game; +using AscNet.Common.Util; +using AscNet.Table.V2.share.fuben.simulatetrain; +using MessagePack; + +namespace AscNet.GameServer.Handlers; + +[MessagePackObject(true)] +public sealed class SimulateTrainFightResultData +{ + public int AtkLevel { get; set; } + public int HpLevel { get; set; } + public int Difficulty { get; set; } + public long FightTime { get; set; } +} + +[MessagePackObject(true)] +public sealed class SimulateTrainNpcGroupData +{ + public List NpcList { get; set; } = []; +} + +[MessagePackObject(true)] +public sealed class SimulateTrainNpcData +{ + public int NpcId { get; set; } + public List BufferIds { get; set; } = []; + public int Level { get; set; } + public List MagicInfos { get; set; } = []; + public Dictionary AttrTable { get; set; } = []; +} + +internal static class SimulateTrainModule +{ + private const int FightFramesPerSecond = 20; + + private sealed record Data( + IReadOnlyDictionary MonstersByStage, + IReadOnlyDictionary AttackLevels, + IReadOnlyDictionary HealthLevels, + IReadOnlyDictionary<(int BossId, int Period), int> PeriodBuffs); + + private static readonly Lazy Runtime = new(Load); + + public static bool IsStage(uint stageId) => Runtime.Value.MonstersByStage.ContainsKey(stageId); + + public static bool TryApplyPreFight( + PreFightRequest.PreFightRequestPreFightData request, + PreFightResponse.PreFightResponseFightData fightData, + out int code) + { + code = 0; + if (!Runtime.Value.MonstersByStage.TryGetValue(request.StageId, out SimulateTrainMonsterTable? monster)) + return false; + + PreFightRequest.PreFightRequestPreFightData.SimulateTrainInfoData? info = request.SimulateTrainInfo; + int difficultyIndex = (info?.Difficulty ?? 0) - 1; + if (info is null + || info.BossId != monster.Id + || difficultyIndex < 0 + || difficultyIndex >= monster.NpcId.Count + || difficultyIndex >= monster.NpcLevel.Count + || difficultyIndex >= monster.StageBuffId.Count + || !Runtime.Value.AttackLevels.TryGetValue(info.AtkLevel, out SimulateTrainAtkTable? attack) + || !Runtime.Value.HealthLevels.TryGetValue(info.HpLevel, out SimulateTrainHpTable? health)) + { + code = 1; + return true; + } + + bool hasPeriodBuff = Runtime.Value.PeriodBuffs.TryGetValue( + (info.BossId, info.Period), + out int periodBuffId); + if (info.Period < 1 || (info.Period > 1 && !hasPeriodBuff)) + { + code = 1; + return true; + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + bool isImpasseDifficulty = monster.ImpasseTimeId > 0 + && difficultyIndex == monster.NpcId.Count - 1; + if (!IsTimeOpen(monster.TimeId, now) + || (isImpasseDifficulty && !IsTimeOpen(monster.ImpasseTimeId, now))) + { + code = FashionStoryModule.StageLocked; + return true; + } + + List bufferIds = [monster.StageBuffId[difficultyIndex]]; + if (hasPeriodBuff) + bufferIds.Add(periodBuffId); + bufferIds.Add(attack.AtkBuffId); + bufferIds.Add(health.HpBuffId); + bufferIds = bufferIds.Where(id => id > 0).Distinct().ToList(); + + fightData.FightCheckType = 1; + fightData.SegmentFightCheckSecond = 60; + fightData.MonsterLevel = null; + fightData.EventIds = []; + fightData.FightEventsWithLevel = new List(); + fightData.NormalEventIds = [2]; + fightData.NpcGroupList = new List + { + new() + { + NpcList = + [ + new() + { + NpcId = monster.NpcId[difficultyIndex], + BufferIds = bufferIds, + Level = monster.NpcLevel[difficultyIndex], + } + ] + } + }; + fightData.Records = new Dictionary(); + fightData.StageParams = new Dictionary(); + fightData.Restartable = true; + return true; + } + + public static SimulateTrainFightResultData? BuildFightResult( + PreFightRequest.PreFightRequestPreFightData? preFight, + FightSettleResult settle) + { + PreFightRequest.PreFightRequestPreFightData.SimulateTrainInfoData? info = preFight?.SimulateTrainInfo; + if (preFight is null || info is null || !IsStage(settle.StageId)) + return null; + + long activeFrames = Math.Max(0, settle.SettleFrame - settle.StartFrame - settle.PauseFrame); + return new SimulateTrainFightResultData + { + AtkLevel = info.AtkLevel, + HpLevel = info.HpLevel, + Difficulty = info.Difficulty, + FightTime = activeFrames / FightFramesPerSecond, + }; + } + + public static NotifyArchiveMonsterRecord? RecordArchiveKill( + Player player, + PreFightRequest.PreFightRequestPreFightData? preFight, + FightSettleResult settle) + { + PreFightRequest.PreFightRequestPreFightData.SimulateTrainInfoData? info = preFight?.SimulateTrainInfo; + if (!settle.IsWin + || settle.IsForceExit + || info is null + || !Runtime.Value.MonstersByStage.TryGetValue(settle.StageId, out SimulateTrainMonsterTable? monster)) + { + return null; + } + + int difficultyIndex = info.Difficulty - 1; + if (difficultyIndex < 0 || difficultyIndex >= monster.NpcId.Count) + return null; + + int npcId = monster.NpcId[difficultyIndex]; + player.ArchiveMonsterKills ??= []; + int killed = player.ArchiveMonsterKills.TryGetValue(npcId, out int previous) + ? checked(previous + 1) + : 1; + player.ArchiveMonsterKills[npcId] = killed; + return new NotifyArchiveMonsterRecord + { + Monsters = + [ + new() + { + Id = checked((uint)npcId), + Killed = checked((uint)killed), + } + ] + }; + } + + private static bool IsTimeOpen(int timeId, DateTimeOffset now) => + timeId == 0 || ActivityScheduleService.IsOpen(timeId, now); + + private static Data Load() + { + Dictionary monsters = TableReaderV2.Parse() + .ToDictionary(monster => checked((uint)monster.StageId)); + Dictionary attackLevels = TableReaderV2.Parse() + .ToDictionary(level => level.AtkLevel); + Dictionary healthLevels = TableReaderV2.Parse() + .ToDictionary(level => level.HpLevel); + Dictionary<(int BossId, int Period), int> periodBuffs = TableReaderV2.Parse() + .ToDictionary(row => (row.BossId, row.Period), row => row.BuffId); + + return new Data(monsters, attackLevels, healthLevels, periodBuffs); + } +} diff --git a/AscNet.Test/Program.SimulateTrainCompatibility.cs b/AscNet.Test/Program.SimulateTrainCompatibility.cs new file mode 100644 index 00000000..5684bd72 --- /dev/null +++ b/AscNet.Test/Program.SimulateTrainCompatibility.cs @@ -0,0 +1,265 @@ +using AscNet.Common.Database; +using AscNet.Common.MsgPack; +using AscNet.Common.Util; +using AscNet.Table.V2.share.fuben.simulatetrain; +using AscNet.GameServer.Handlers; +using AscNet.GameServer.Game; +using MessagePack; +using Newtonsoft.Json.Linq; +using System.Reflection; + +namespace AscNet.Test; + +internal partial class Program +{ + private static void ValidateSimulateTrainCompatibility() + { + List bosses = TableReaderV2.Parse(); + AssertEqual(true, bosses.Count > 0, "SimulateTrain monster table is not empty"); + AssertEqual(bosses.Count, bosses.Select(boss => boss.Id).Distinct().Count(), + "SimulateTrain boss IDs are unique"); + AssertEqual(bosses.Count, bosses.Select(boss => boss.StageId).Distinct().Count(), + "SimulateTrain stage IDs are unique"); + SimulateTrainMonsterTable ronin = bosses.Single(boss => boss.Id == 2_001); + AssertEqual(true, ronin.NpcLevel.SequenceEqual([170, 205, 250]), + "SimulateTrain Ronin difficulty levels"); + AssertEqual(true, ronin.StageBuffId.SequenceEqual([750_976, 750_977, 750_978]), + "SimulateTrain Ronin difficulty buffs"); + AssertEqual(201, ronin.TimeId, "SimulateTrain Ronin permanent TimeId"); + AssertEqual(0, ronin.ImpasseTimeId, "SimulateTrain Ronin ImpasseTimeId"); + AssertEqual(true, ActivityScheduleService.IsOpen(ronin.TimeId, DateTimeOffset.UtcNow), + "SimulateTrain permanent practice schedule is open"); + List periodBuffs = + TableReaderV2.Parse(); + AssertEqual(22, periodBuffs.Count, "SimulateTrain period buff row count"); + + PreFightRequest request = new() + { + PreFightData = new() + { + StageId = 30_161_351, + SimulateTrainInfo = new() + { + BossId = 2_001, + Period = 1, + AtkLevel = 2, + HpLevel = 1, + Difficulty = 3, + DangerCoefficient = 150, + } + } + }; + request = MessagePackSerializer.Deserialize( + MessagePackSerializer.Serialize(request)); + AssertEqual(2_001, request.PreFightData.SimulateTrainInfo?.BossId ?? 0, + "PreFightRequest preserves SimulateTrainInfo"); + + Type simulateTrainModule = RequiredAscNetGameServerType( + "AscNet.GameServer.Handlers.SimulateTrainModule"); + MethodInfo applyPreFight = RequiredMethod( + simulateTrainModule, + "TryApplyPreFight", + BindingFlags.Static | BindingFlags.Public, + [ + typeof(PreFightRequest.PreFightRequestPreFightData), + typeof(PreFightResponse.PreFightResponseFightData), + typeof(int).MakeByRefType() + ]); + PreFightResponse.PreFightResponseFightData fightData = new() { StageId = request.PreFightData.StageId }; + object?[] preFightArguments = [request.PreFightData, fightData, 0]; + AssertEqual(true, (bool)(applyPreFight.Invoke(null, preFightArguments) ?? false), + "SimulateTrain pre-fight is recognized"); + AssertEqual(0, (int)(preFightArguments[2] ?? -1), + "SimulateTrain pre-fight is accepted"); + AssertEqual?>(null, fightData.MonsterLevel, + "SimulateTrain uses explicit NPC group instead of top-level monster levels"); + AssertEqual(0, fightData.EventIds.Count, + "SimulateTrain uses NPC buffers instead of top-level fight events"); + AssertEqual(true, fightData.NormalEventIds.Cast().SequenceEqual([2]), + "SimulateTrain applies the normal stage event"); + if (fightData.NpcGroupList is not List roninGroups) + throw new InvalidDataException("SimulateTrain pre-fight NpcGroupList had the wrong runtime type."); + SimulateTrainNpcData roninNpc = roninGroups.Single().NpcList.Single(); + AssertEqual(90_250, roninNpc.NpcId, + "SimulateTrain hard Ronin NPC"); + AssertEqual(250, roninNpc.Level, + "SimulateTrain hard Ronin level"); + AssertEqual(true, roninNpc.BufferIds.SequenceEqual( + [750_978, 750_962, 750_955]), + "SimulateTrain hard Ronin NPC buffers"); + + PreFightRequest officialCaptureRequest = new() + { + PreFightData = new() + { + StageId = 30_161_303, + SimulateTrainInfo = new() + { + BossId = 3_021, + Period = 2, + AtkLevel = 2, + HpLevel = 1, + Difficulty = 1, + DangerCoefficient = 3_000, + } + } + }; + PreFightResponse.PreFightResponseFightData officialCaptureFightData = + new() { StageId = officialCaptureRequest.PreFightData.StageId }; + object?[] officialCaptureArguments = + [officialCaptureRequest.PreFightData, officialCaptureFightData, 0]; + AssertEqual(true, (bool)(applyPreFight.Invoke(null, officialCaptureArguments) ?? false), + "Official captured SimulateTrain pre-fight is recognized"); + AssertEqual(0, (int)(officialCaptureArguments[2] ?? -1), + "Official captured SimulateTrain pre-fight is accepted"); + JObject officialWire = JObject.Parse(MessagePackSerializer.ConvertToJson( + MessagePackSerializer.Serialize(new PreFightResponse + { + Code = 0, + FightData = officialCaptureFightData, + }))); + JObject officialWireFightData = (JObject)officialWire["FightData"]!; + AssertEqual(JTokenType.Null, officialWireFightData["MonsterLevel"]!.Type, + "Official captured SimulateTrain wire MonsterLevel"); + AssertEqual(true, ((JArray)officialWireFightData["EventIds"]!).Count == 0, + "Official captured SimulateTrain wire EventIds"); + AssertEqual(true, ((JArray)officialWireFightData["NormalEventIds"]!) + .Values().SequenceEqual([2]), + "Official captured SimulateTrain wire NormalEventIds"); + JObject officialWireNpc = (JObject)officialWireFightData["NpcGroupList"]![0]!["NpcList"]![0]!; + AssertEqual(837_000, officialWireNpc["NpcId"]!.Value(), + "Official captured SimulateTrain wire NPC"); + AssertEqual(400, officialWireNpc["Level"]!.Value(), + "Official captured SimulateTrain wire NPC level"); + AssertEqual(true, ((JArray)officialWireNpc["BufferIds"]!).Values().SequenceEqual( + [750_976, 750_953, 750_962, 750_955]), + "Official captured SimulateTrain wire NPC buffers"); + + request.PreFightData.SimulateTrainInfo!.Period = 2; + object?[] unsupportedPeriodArguments = + [ + request.PreFightData, + new PreFightResponse.PreFightResponseFightData { StageId = request.PreFightData.StageId }, + 0 + ]; + AssertEqual(true, (bool)(applyPreFight.Invoke(null, unsupportedPeriodArguments) ?? false), + "SimulateTrain unsupported period is recognized"); + AssertEqual(true, (int)(unsupportedPeriodArguments[2] ?? 0) != 0, + "SimulateTrain unsupported period is rejected"); + request.PreFightData.SimulateTrainInfo.Period = 1; + + SimulateTrainMonsterTable timeGatedBoss = bosses.Single(boss => boss.Id == 3_066); + PreFightRequest timeGatedRequest = new() + { + PreFightData = new() + { + StageId = checked((uint)timeGatedBoss.StageId), + SimulateTrainInfo = new() + { + BossId = timeGatedBoss.Id, + Period = 1, + AtkLevel = 2, + HpLevel = 1, + Difficulty = 1, + } + } + }; + object?[] timeGatedArguments = + [ + timeGatedRequest.PreFightData, + new PreFightResponse.PreFightResponseFightData { StageId = timeGatedRequest.PreFightData.StageId }, + 0 + ]; + AssertEqual(true, (bool)(applyPreFight.Invoke(null, timeGatedArguments) ?? false), + "SimulateTrain time-gated boss is recognized"); + AssertEqual(20_003_024, (int)(timeGatedArguments[2] ?? 0), + "SimulateTrain closed boss schedule is rejected"); + + request.PreFightData.SimulateTrainInfo!.BossId = 9_999; + object?[] invalidPreFightArguments = + [ + request.PreFightData, + new PreFightResponse.PreFightResponseFightData { StageId = request.PreFightData.StageId }, + 0 + ]; + AssertEqual(true, (bool)(applyPreFight.Invoke(null, invalidPreFightArguments) ?? false), + "SimulateTrain invalid pre-fight is recognized"); + AssertEqual(true, (int)(invalidPreFightArguments[2] ?? 0) != 0, + "SimulateTrain mismatched boss is rejected"); + request.PreFightData.SimulateTrainInfo.BossId = 2_001; + + MethodInfo buildFightResult = RequiredMethod( + simulateTrainModule, + "BuildFightResult", + BindingFlags.Static | BindingFlags.Public, + [typeof(PreFightRequest.PreFightRequestPreFightData), typeof(FightSettleResult)]); + SimulateTrainFightResultData result = (SimulateTrainFightResultData)(buildFightResult.Invoke( + null, + [ + request.PreFightData, + new FightSettleResult + { + StageId = request.PreFightData.StageId, + StartFrame = 100, + SettleFrame = 500, + PauseFrame = 40, + } + ]) ?? throw new InvalidDataException("SimulateTrainModule.BuildFightResult returned nil.")); + AssertEqual(3, result.Difficulty, "SimulateTrain settle difficulty"); + AssertEqual(2, result.AtkLevel, "SimulateTrain settle attack level"); + AssertEqual(1, result.HpLevel, "SimulateTrain settle health level"); + AssertEqual(18L, result.FightTime, "SimulateTrain settle fight time"); + + + Type accountModule = RequiredAscNetGameServerType("AscNet.GameServer.Handlers.AccountModule"); + MethodInfo buildArchive = RequiredMethod( + accountModule, + "BuildNotifyArchiveLoginData", + BindingFlags.Static | BindingFlags.NonPublic, + [typeof(Player)]); + HashSet visibilityNpcIds = bosses + .Select(boss => checked((uint)boss.NpcId.First())) + .ToHashSet(); + Player freshPlayer = CreateDrawCompatibilityPlayer(88_301); + NotifyArchiveLoginData freshPayload = (NotifyArchiveLoginData)(buildArchive.Invoke( + null, + [freshPlayer]) + ?? throw new InvalidDataException("AccountModule.BuildNotifyArchiveLoginData returned nil.")); + AssertEqual(true, freshPayload.MonsterUnlockIds.Order().SequenceEqual( + bosses.Select(boss => checked((uint)boss.Id)).Order()), + "SimulateTrain archive unlock IDs match the configured catalog"); + AssertEqual(true, freshPayload.Monsters.Select(monster => monster.Id).ToHashSet() + .SetEquals(visibilityNpcIds), + "Fresh SimulateTrain archive receives one visibility record per boss"); + AssertEqual(true, freshPayload.Monsters.All(monster => monster.Killed == 0), + "Fresh SimulateTrain visibility records do not fabricate kills"); + + Player archivePlayer = CreateDrawCompatibilityPlayer(88_302); + archivePlayer.ArchiveMonsterKills = new() { [90_250] = 3, [99_999] = 0 }; + NotifyArchiveLoginData payload = (NotifyArchiveLoginData)(buildArchive.Invoke( + null, + [archivePlayer]) + ?? throw new InvalidDataException("AccountModule.BuildNotifyArchiveLoginData returned nil.")); + AssertEqual(1, payload.Monsters.Count(monster => monster.Killed > 0), + "SimulateTrain archive exposes only persisted positive kill counts"); + AssertEqual(3, payload.Monsters.Single(monster => monster.Id == 90_250).Killed, + "SimulateTrain archive login preserves the persisted kill count"); + + MethodInfo recordArchiveKill = RequiredMethod( + simulateTrainModule, + "RecordArchiveKill", + BindingFlags.Static | BindingFlags.Public, + [typeof(Player), typeof(PreFightRequest.PreFightRequestPreFightData), typeof(FightSettleResult)]); + NotifyArchiveMonsterRecord archiveRecord = (NotifyArchiveMonsterRecord)(recordArchiveKill.Invoke( + null, + [ + archivePlayer, + request.PreFightData, + new FightSettleResult { IsWin = true, StageId = request.PreFightData.StageId } + ]) ?? throw new InvalidDataException("SimulateTrainModule.RecordArchiveKill returned nil.")); + AssertEqual(4, archivePlayer.ArchiveMonsterKills[90_250], + "SimulateTrain clear persists the real boss kill"); + AssertEqual(4U, archiveRecord.Monsters.Single().Killed, + "SimulateTrain clear pushes the persisted boss kill count"); + } +} diff --git a/AscNet.Test/Program.cs b/AscNet.Test/Program.cs index 4fa7e522..c7685ce7 100644 --- a/AscNet.Test/Program.cs +++ b/AscNet.Test/Program.cs @@ -100,6 +100,11 @@ static void Main(string[] args) ValidateAssignCompatibility(); return; } + if (args.Contains("--simulate-train-compat-only")) + { + ValidateSimulateTrainCompatibility(); + return; + } if (args.Contains("--transfinite-compat-only")) { ValidateTransfiniteCompatibility(); @@ -24453,7 +24458,9 @@ private static void AssertStudyStageRobotDeployments( { AssertIntegerList( expectedMonsterLevels.Select(level => (long)level).ToArray(), - preFightResponse.FightData.MonsterLevel.Select(level => (long)level).ToArray(), + (preFightResponse.FightData.MonsterLevel + ?? throw new InvalidDataException($"{name} MonsterLevel was nil.")) + .Select(level => (long)level).ToArray(), $"{name} MonsterLevel"); } AssertPreFightDoesNotDeployCharacter( @@ -26949,8 +26956,10 @@ void AssertRollover( AssertEqual(0, repeatChallengePreFight.Code, "Simulated Battlefield PreFightResponse Code"); AssertEqual(30_090_802U, repeatChallengePreFight.FightData.StageId, "Simulated Battlefield PreFightResponse StageId"); AssertEqual(0, repeatChallengePreFight.FightData.RebootId, "Simulated Battlefield PreFightResponse RebootId"); - if (!repeatChallengePreFight.FightData.MonsterLevel.SequenceEqual([357, 252, 197])) - throw new InvalidDataException($"Simulated Battlefield PreFightResponse MonsterLevel: expected 357,252,197, got {string.Join(",", repeatChallengePreFight.FightData.MonsterLevel)}."); + List repeatChallengeMonsterLevels = repeatChallengePreFight.FightData.MonsterLevel + ?? throw new InvalidDataException("Simulated Battlefield PreFightResponse MonsterLevel was nil."); + if (!repeatChallengeMonsterLevels.SequenceEqual([357, 252, 197])) + throw new InvalidDataException($"Simulated Battlefield PreFightResponse MonsterLevel: expected 357,252,197, got {string.Join(",", repeatChallengeMonsterLevels)}."); AssertEqual(51201, Convert.ToInt32(repeatChallengePreFight.FightData.EventIds.Single()), "Simulated Battlefield Authority Level 1 effect"); const int repeatChallengeSettlePacketId = 81_006; diff --git a/Resources/table/share/activity/ActivitySchedule.tsv b/Resources/table/share/activity/ActivitySchedule.tsv index 34855f6f..8a2e50b6 100644 --- a/Resources/table/share/activity/ActivitySchedule.tsv +++ b/Resources/table/share/activity/ActivitySchedule.tsv @@ -1,4 +1,5 @@ Id StartTime EndTime Source +201 0 0 SimulateTrain:permanent-practice 716 1787115600 1787806740 version-history:EN-LIVE:4.6.6@bb3c34765c9d9c1c542079d536a17e82b27f3245/TransfiniteActivity+EN-LIVE:4.5.6@69465ac7168a8f33f517f2ffd6e366aa9f3c3357/TransfiniteActivity+LoginNotice:EndTime+GameNotice:update-note-EndTime+maintenance-duration+CycleSeconds 717 1787806800 1789016340 version-history:EN-LIVE:4.6.6@bb3c34765c9d9c1c542079d536a17e82b27f3245/TransfiniteActivity+EN-LIVE:4.5.6@69465ac7168a8f33f517f2ffd6e366aa9f3c3357/TransfiniteActivity+LoginNotice:EndTime+GameNotice:update-note-EndTime+maintenance-duration+CycleSeconds 48032 1787115600 1790226000 version-history:EN-LIVE:4.6.6@bb3c34765c9d9c1c542079d536a17e82b27f3245/DrawCanLiverActivity+EN-LIVE:4.5.6@0236666432b01e5469a51047f4b112068fff8763/DrawCanLiverActivity+LoginNotice:EndTime+GameNotice:update-note-EndTime+maintenance-duration diff --git a/Resources/table/share/fuben/simulatetrain/SimulateTrainAtk.tsv b/Resources/table/share/fuben/simulatetrain/SimulateTrainAtk.tsv new file mode 100644 index 00000000..16aa7351 --- /dev/null +++ b/Resources/table/share/fuben/simulatetrain/SimulateTrainAtk.tsv @@ -0,0 +1,7 @@ +AtkLevel AtkBuffId +1 750961 +2 750962 +3 750963 +4 750964 +5 750965 +6 750966 diff --git a/Resources/table/share/fuben/simulatetrain/SimulateTrainHp.tsv b/Resources/table/share/fuben/simulatetrain/SimulateTrainHp.tsv new file mode 100644 index 00000000..31bd74ee --- /dev/null +++ b/Resources/table/share/fuben/simulatetrain/SimulateTrainHp.tsv @@ -0,0 +1,7 @@ +HpLevel HpBuffId +1 750955 +2 750956 +3 750957 +4 750958 +5 750959 +6 750960 diff --git a/Resources/table/share/fuben/simulatetrain/SimulateTrainMonster.tsv b/Resources/table/share/fuben/simulatetrain/SimulateTrainMonster.tsv new file mode 100644 index 00000000..269aac35 --- /dev/null +++ b/Resources/table/share/fuben/simulatetrain/SimulateTrainMonster.tsv @@ -0,0 +1,77 @@ +Id TimeId ImpasseTimeId StageId NpcId[1] NpcId[2] NpcId[3] NpcId[4] NpcLevel[1] NpcLevel[2] NpcLevel[3] NpcLevel[4] StageBuffId[1] StageBuffId[2] StageBuffId[3] StageBuffId[4] +2001 201 0 30161351 92250 91250 90250 170 205 250 750976 750977 750978 +2002 201 0 30161352 92260 91260 90260 220 345 420 750976 750977 750978 +2003 201 0 30161353 92330 91330 90330 240 285 345 750976 750977 750978 +2004 201 0 30161354 92350 91350 90350 830 1070 1580 750976 750977 750978 +2005 201 0 30161355 92360 91360 90360 290 345 415 750976 750977 750978 +2006 201 0 30161356 92370 91370 90370 220 345 415 750976 750977 750978 +2007 201 0 30161357 92220 91220 90222 160 190 230 750976 750977 750978 +2008 201 0 30161358 92390 91390 90390 150 195 235 750976 750977 750978 +2009 201 0 30161359 92400 91400 90400 200 240 290 750976 750977 750978 +2010 201 0 30161360 92410 91410 90410 144 220 295 750976 750977 750978 +2011 201 0 30161361 92440 91440 90440 220 340 405 750976 750977 750978 +2012 201 0 30161362 92340 91340 90340 240 275 330 750976 750977 750978 +2013 201 0 30161363 92380 91382 90380 250 385 465 750976 750977 750978 +2014 201 0 30161364 92420 91420 90420 165 250 305 750976 750977 750978 +2015 201 0 30161365 92450 91450 90450 240 280 335 750976 750977 750978 +2016 201 0 30161366 92630 91630 90630 395 455 550 750976 750977 750978 +2018 201 0 30161367 92680 91680 90680 235 270 600 750976 750977 750978 +2019 201 0 30161368 90700 90700 90700 380 485 580 750976 750977 750978 +2020 201 0 30161369 92740 91740 90740 430 440 595 750976 750977 750978 +2021 201 0 30161370 92780 91780 90780 630 650 880 750976 750977 750978 +2022 201 0 30161371 92790 91790 90790 440 455 615 750976 750977 750978 +2023 201 0 30161372 92800 91800 90800 385 395 585 750976 750977 750978 +2024 201 0 30161373 92870 91870 90870 272 346 415 750976 750977 750978 +2025 201 0 30161374 92910 91910 90910 310 394 474 750976 750977 750978 +2026 201 0 30161375 92950 91950 90950 220 280 336 750976 750977 750978 +2027 201 0 30161376 92980 91980 90980 311 395 475 750976 750977 750978 +2028 201 0 30161377 92990 91990 90990 318 404 486 750976 750977 750978 +2029 201 0 30161378 93072 93071 93070 288 366 440 750976 750977 750978 +2031 201 0 30161380 93142 93141 93140 308 391 470 750976 750977 750978 +2032 201 0 30161381 93132 93131 93130 308 391 470 750976 750977 750978 +2033 201 0 30161379 93122 93121 93120 308 391 470 750976 750977 750978 +3002 201 0 30161301 81300 81310 81310 710 910 1100 750976 750977 750978 +3006 201 0 30161310 81200 81200 81200 345 440 525 750976 750977 750978 +3007 201 0 30161302 81500 81510 81510 950 1200 1465 750976 750977 750978 +3008 201 0 30161307 81700 81700 81700 1805 2295 2765 750976 750977 750978 +3009 201 0 30161317 81800 81800 81800 695 880 1065 750976 750977 750978 +3010 201 0 30161308 81900 81900 81900 670 855 1030 750976 750977 750978 +3013 201 0 30161312 81610 81610 81610 565 720 870 750976 750977 750978 +3014 201 0 30161304 83220 83230 83230 420 695 1000 750976 750977 750978 +3015 201 0 30161315 83100 83100 83100 480 610 735 750976 750977 750978 +3016 201 0 30161318 87360 87360 87360 640 810 980 750976 750977 750978 +3018 201 0 30161313 83500 83500 83500 1700 2160 2605 750976 750977 750978 +3019 201 0 30161326 83520 83520 83520 360 556 722 750976 750977 750978 +3020 201 0 30161311 83610 83610 83610 435 555 670 750976 750977 750978 +3021 201 0 30161303 837000 837011 837012 400 520 630 750976 750977 750978 +3024 201 0 30161314 83900 83900 83900 385 485 585 750976 750977 750978 +3025 201 0 30161316 841000 841000 841000 480 610 735 750976 750977 750978 +3030 201 0 30161305 84200 84200 84200 345 700 1000 750976 750977 750978 +3031 201 0 30161309 84300 84300 84300 190 245 295 750976 750977 750978 +3032 201 0 30161306 84400 84400 84400 230 290 350 750976 750977 750978 +3033 201 0 30161319 845000 845000 845000 300 350 400 750976 750977 750978 +3034 201 0 30161320 846000 846000 846000 355 451 542 750976 750977 750978 +3035 201 0 30161321 85010 85010 85010 350 444 534 750976 750977 750978 +3036 201 0 30161323 848100 848100 848100 75 116 151 750976 750977 750978 +3038 201 0 30161322 82200 82200 82200 576 732 880 750976 750977 750978 +3039 201 0 30161324 85100 85100 85100 446 689 893 750976 750977 750978 +3040 201 0 30161325 85300 85300 85300 250 386 500 750976 750977 750978 +3041 201 0 30161327 85400 85400 85400 702 1084 1406 750976 750977 750978 +3043 201 0 30161328 85480 85480 85480 222 343 445 750976 750977 750978 +3044 201 0 30161329 85350 85350 85350 225 347 450 750976 750977 750978 +3045 201 0 30161335 81720 81720 81720 104 160 208 750976 750977 750978 +3048 32315 32315 30161331 85570 85570 85570 85570 275 412 824 824 750976 750977 750978 751141 +3049 201 0 30161332 85620 85620 85620 360 556 722 750976 750977 750978 +3050 36301 36301 30161333 82300 82300 82300 82300 295 492 722 722 750976 750977 750978 751141 +3051 201 0 30161334 83340 83340 83340 360 556 722 750976 750977 750978 +3052 30308 30308 30161337 83130 83130 83130 83130 554 866 1043 1043 750976 750977 750978 751141 +3054 201 0 30161336 82350 82350 82350 240 371 482 750976 750977 750978 +3056 30307 30307 30161338 85650 85650 85650 85650 143 223 268 268 750976 750977 750978 751141 +3057 30306 30306 30161339 85770 85770 85770 85770 180 282 339 339 750976 750977 750978 751141 +3058 30305 30305 30161340 85670 85670 85670 85670 240 375 452 452 750976 750977 750978 751141 +3059 32316 32316 30161343 85751 85751 85751 85751 244 366 730 730 750976 750977 750978 751141 +3060 36302 36302 30161394 87620 87620 87620 87620 140 240 360 360 750976 750977 750978 751141 +3062 32314 32314 30161345 87590 87590 87590 87590 135 201 402 402 750976 750977 750978 751141 +3064 32313 32313 30161344 87650 87650 87650 87650 240 375 452 452 750976 750977 750978 751141 +3065 36303 36303 30161395 87752 87752 87752 87752 163 233 327 327 750976 750977 750978 751141 +3066 36304 36304 30161396 87691 87691 87691 87691 130 218 327 327 750976 750977 750978 751141 diff --git a/Resources/table/share/fuben/simulatetrain/SimulateTrainPeriodBuff.tsv b/Resources/table/share/fuben/simulatetrain/SimulateTrainPeriodBuff.tsv new file mode 100644 index 00000000..9eecacb2 --- /dev/null +++ b/Resources/table/share/fuben/simulatetrain/SimulateTrainPeriodBuff.tsv @@ -0,0 +1,23 @@ +BossId Period BuffId +3008 2 750953 +3020 2 750953 +3021 2 750953 +3024 2 750953 +3030 2 750953 +3031 2 750953 +3034 2 750953 +3036 2 750953 +3040 2 750953 +3041 2 750953 +3043 2 750953 +3044 2 750953 +3045 2 750953 +3049 2 750953 +3050 2 750953 +3051 2 750953 +3056 2 750953 +3058 2 750953 +3059 2 750953 +3060 2 750953 +3065 2 750953 +3066 2 750953 diff --git a/Scripts/generate_current_simulate_train_tables.py b/Scripts/generate_current_simulate_train_tables.py new file mode 100644 index 00000000..b21dabf0 --- /dev/null +++ b/Scripts/generate_current_simulate_train_tables.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Generate minimal Celica boss-practice TSV tables from current client data.""" +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path +from typing import Any, Iterable + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_SOURCE = REPO_ROOT.parent / "PGR_DATA" / "en" / "bytes" / "share" +DEFAULT_OUTPUT = REPO_ROOT / "Resources" / "table" / "share" / "fuben" / "simulatetrain" + + +def resolve_source(source: Path) -> Path: + nested = source / "fuben" / "simulatetrain" + return nested if nested.is_dir() else source + + +def load_rows(source: Path, table_name: str) -> list[dict[str, Any]]: + json_path = source / f"{table_name}.json" + csv_path = source / f"{table_name.lower()}.csv" + if json_path.is_file(): + try: + value = json.loads(json_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"{json_path}: unable to load JSON: {exc}") from exc + if not isinstance(value, list) or not all(isinstance(row, dict) for row in value): + raise ValueError(f"{json_path}: expected an array of objects") + return value + if csv_path.is_file(): + try: + with csv_path.open("r", encoding="utf-8-sig", newline="") as handle: + return list(csv.DictReader(handle)) + except (OSError, csv.Error) as exc: + raise ValueError(f"{csv_path}: unable to load CSV: {exc}") from exc + raise ValueError(f"missing {json_path.name} or {csv_path.name} in {source}") + + +def integer(row: dict[str, Any], field: str, *, default: int | None = None) -> int: + value = row.get(field) + if value in (None, ""): + if default is not None: + return default + raise ValueError(f"row {row!r} is missing {field}") + if isinstance(value, bool): + raise ValueError(f"{field} must be an integer, got {value!r}") + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be an integer, got {value!r}") from exc + + +def integer_list(row: dict[str, Any], field: str) -> list[int]: + value = row.get(field) + if isinstance(value, list): + return [int(item) for item in value if item not in (None, "")] + + indexed: list[tuple[int, int]] = [] + prefix = field + "[" + for key, item in row.items(): + if not key.startswith(prefix) or not key.endswith("]") or item in (None, ""): + continue + try: + index = int(key[len(prefix):-1]) + indexed.append((index, int(item))) + except ValueError as exc: + raise ValueError(f"invalid {field} entry {key}={item!r}") from exc + return [item for _, item in sorted(indexed)] + + +def period_buffs(row: dict[str, Any]) -> list[tuple[int, int]]: + value = row.get("PeriodBuffId") + if isinstance(value, dict): + entries = sorted((int(period), int(buff_id)) for period, buff_id in value.items() if int(buff_id) > 0) + if entries and entries[0][0] == 0: + return [(period + 1, buff_id) for period, buff_id in entries] + return entries + if isinstance(value, list): + return [(index + 1, int(buff_id)) for index, buff_id in enumerate(value) if buff_id not in (None, "", 0)] + + entries: list[tuple[int, int]] = [] + prefix = "PeriodBuffId[" + for key, item in row.items(): + if not key.startswith(prefix) or not key.endswith("]") or item in (None, "", 0, "0"): + continue + try: + entries.append((int(key[len(prefix):-1]) + 1, int(item))) + except ValueError as exc: + raise ValueError(f"invalid period buff entry {key}={item!r}") from exc + return sorted(entries) + + +def scalar(value: Any) -> str: + text = str(value) + if "\t" in text or "\r" in text or "\n" in text: + raise ValueError(f"TSV scalar contains a tab or newline: {text!r}") + return text + + +def table(columns: list[str], rows: Iterable[list[Any]]) -> bytes: + lines = ["\t".join(columns)] + for row in rows: + if len(row) != len(columns): + raise ValueError(f"expected {len(columns)} columns, got {len(row)}") + lines.append("\t".join(scalar(value) for value in row).rstrip("\t")) + return ("\n".join(lines) + "\n").encode("utf-8") + + +def repeated_columns(name: str, width: int) -> list[str]: + return [f"{name}[{index}]" for index in range(1, width + 1)] + + +def padded(values: list[int], width: int) -> list[int | str]: + return values + [""] * (width - len(values)) + + +def generate(source: Path) -> dict[str, bytes]: + source = resolve_source(source) + monsters = load_rows(source, "SimulateTrainMonster") + attacks = load_rows(source, "SimulateTrainAtk") + health = load_rows(source, "SimulateTrainHp") + + monster_ids: set[int] = set() + stage_ids: set[int] = set() + normalized_monsters: list[tuple[int, int, int, int, list[int], list[int], list[int]]] = [] + normalized_periods: list[tuple[int, int, int]] = [] + for row in monsters: + boss_id = integer(row, "Id") + stage_id = integer(row, "StageId") + if boss_id in monster_ids: + raise ValueError(f"duplicate SimulateTrain boss Id {boss_id}") + if stage_id in stage_ids: + raise ValueError(f"duplicate SimulateTrain StageId {stage_id}") + monster_ids.add(boss_id) + stage_ids.add(stage_id) + + npc_ids = integer_list(row, "NpcId") + npc_levels = integer_list(row, "NpcLevel") + stage_buffs = integer_list(row, "StageBuffId") + if not npc_ids or len(npc_ids) != len(npc_levels) or len(npc_ids) != len(stage_buffs): + raise ValueError( + f"boss {boss_id}: NpcId, NpcLevel, and StageBuffId must have equal non-zero lengths") + normalized_monsters.append(( + boss_id, + integer(row, "TimeId", default=0), + integer(row, "ImpasseTimeId", default=0), + stage_id, + npc_ids, + npc_levels, + stage_buffs, + )) + normalized_periods.extend((boss_id, period, buff_id) for period, buff_id in period_buffs(row)) + + normalized_monsters.sort(key=lambda row: row[0]) + width = max(len(row[4]) for row in normalized_monsters) + monster_columns = (["Id", "TimeId", "ImpasseTimeId", "StageId"] + + repeated_columns("NpcId", width) + + repeated_columns("NpcLevel", width) + + repeated_columns("StageBuffId", width)) + monster_rows = [ + [boss_id, time_id, impasse_time_id, stage_id] + + padded(npc_ids, width) + + padded(npc_levels, width) + + padded(stage_buffs, width) + for boss_id, time_id, impasse_time_id, stage_id, npc_ids, npc_levels, stage_buffs + in normalized_monsters + ] + + normalized_periods.sort() + if len({(boss_id, period) for boss_id, period, _ in normalized_periods}) != len(normalized_periods): + raise ValueError("duplicate SimulateTrain boss/period buff mapping") + + attack_rows = sorted( + ([integer(row, "AtkLevel"), integer(row, "AtkBuffId")] for row in attacks), + key=lambda row: row[0]) + health_rows = sorted( + ([integer(row, "HpLevel"), integer(row, "HpBuffId")] for row in health), + key=lambda row: row[0]) + + return { + "SimulateTrainMonster.tsv": table(monster_columns, monster_rows), + "SimulateTrainAtk.tsv": table(["AtkLevel", "AtkBuffId"], attack_rows), + "SimulateTrainHp.tsv": table(["HpLevel", "HpBuffId"], health_rows), + "SimulateTrainPeriodBuff.tsv": table( + ["BossId", "Period", "BuffId"], + ([boss_id, period, buff_id] for boss_id, period, buff_id in normalized_periods)), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument("--check", action="store_true", help="fail if generated files differ from disk") + args = parser.parse_args() + try: + generated = generate(args.source.resolve()) + output = args.output.resolve() + if args.check: + mismatches = [name for name, content in generated.items() + if not (output / name).is_file() or (output / name).read_bytes() != content] + if mismatches: + raise ValueError("generated output differs: " + ", ".join(mismatches)) + print(f"checked {len(generated)} byte-stable tables in {output}") + return 0 + + output.mkdir(parents=True, exist_ok=True) + for name, content in generated.items(): + (output / name).write_bytes(content) + print("generated " + ", ".join( + f"{name} ({content.count(bytes([10])) - 1} rows)" for name, content in generated.items())) + return 0 + except ValueError as exc: + print(f"error: {exc}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From 4db9966579da422e90b573ba634b8b664080eb3c Mon Sep 17 00:00:00 2001 From: VAGUE000 <45696542+VAGUE000@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:04:16 +0300 Subject: [PATCH 2/3] Fix SimulateTrain schedule authority --- AscNet.GameServer/Handlers/FightModule.cs | 2 +- .../Handlers/SimulateTrainModule.cs | 7 +- .../Program.SimulateTrainCompatibility.cs | 108 +++++++++++++----- Resources/table/manifest.json | 54 ++++++++- .../table/share/activity/ActivitySchedule.tsv | 12 ++ .../generate_current_simulate_train_tables.py | 4 +- 6 files changed, 150 insertions(+), 37 deletions(-) diff --git a/AscNet.GameServer/Handlers/FightModule.cs b/AscNet.GameServer/Handlers/FightModule.cs index f97ee76b..65cdc538 100644 --- a/AscNet.GameServer/Handlers/FightModule.cs +++ b/AscNet.GameServer/Handlers/FightModule.cs @@ -493,7 +493,7 @@ public static void PreFightRequestHandler(Session session, Packet.Request packet return; } - if (SimulateTrainModule.TryApplyPreFight(req.PreFightData, rsp.FightData, out int simulateTrainCode) + if (SimulateTrainModule.TryApplyPreFight(req.PreFightData, rsp.FightData, DateTimeOffset.UtcNow, out int simulateTrainCode) && simulateTrainCode != 0) { rsp.Code = simulateTrainCode; diff --git a/AscNet.GameServer/Handlers/SimulateTrainModule.cs b/AscNet.GameServer/Handlers/SimulateTrainModule.cs index a7982b1f..f57d2dad 100644 --- a/AscNet.GameServer/Handlers/SimulateTrainModule.cs +++ b/AscNet.GameServer/Handlers/SimulateTrainModule.cs @@ -35,6 +35,7 @@ public sealed class SimulateTrainNpcData internal static class SimulateTrainModule { private const int FightFramesPerSecond = 20; + private const int InvalidPreFightData = 1; // Retail code unobserved; any non-zero rejects the request. private sealed record Data( IReadOnlyDictionary MonstersByStage, @@ -49,6 +50,7 @@ private sealed record Data( public static bool TryApplyPreFight( PreFightRequest.PreFightRequestPreFightData request, PreFightResponse.PreFightResponseFightData fightData, + DateTimeOffset now, out int code) { code = 0; @@ -66,7 +68,7 @@ public static bool TryApplyPreFight( || !Runtime.Value.AttackLevels.TryGetValue(info.AtkLevel, out SimulateTrainAtkTable? attack) || !Runtime.Value.HealthLevels.TryGetValue(info.HpLevel, out SimulateTrainHpTable? health)) { - code = 1; + code = InvalidPreFightData; return true; } @@ -75,11 +77,10 @@ public static bool TryApplyPreFight( out int periodBuffId); if (info.Period < 1 || (info.Period > 1 && !hasPeriodBuff)) { - code = 1; + code = InvalidPreFightData; return true; } - DateTimeOffset now = DateTimeOffset.UtcNow; bool isImpasseDifficulty = monster.ImpasseTimeId > 0 && difficultyIndex == monster.NpcId.Count - 1; if (!IsTimeOpen(monster.TimeId, now) diff --git a/AscNet.Test/Program.SimulateTrainCompatibility.cs b/AscNet.Test/Program.SimulateTrainCompatibility.cs index 5684bd72..c60e8068 100644 --- a/AscNet.Test/Program.SimulateTrainCompatibility.cs +++ b/AscNet.Test/Program.SimulateTrainCompatibility.cs @@ -29,6 +29,14 @@ private static void ValidateSimulateTrainCompatibility() AssertEqual(0, ronin.ImpasseTimeId, "SimulateTrain Ronin ImpasseTimeId"); AssertEqual(true, ActivityScheduleService.IsOpen(ronin.TimeId, DateTimeOffset.UtcNow), "SimulateTrain permanent practice schedule is open"); + foreach (int timeId in bosses + .SelectMany(boss => new[] { boss.TimeId, boss.ImpasseTimeId }) + .Where(timeId => timeId > 0) + .Distinct()) + { + AssertEqual(true, ActivityScheduleService.TryGet(timeId, out _), + $"SimulateTrain TimeId {timeId} has an authoritative schedule"); + } List periodBuffs = TableReaderV2.Parse(); AssertEqual(22, periodBuffs.Count, "SimulateTrain period buff row count"); @@ -63,13 +71,15 @@ private static void ValidateSimulateTrainCompatibility() [ typeof(PreFightRequest.PreFightRequestPreFightData), typeof(PreFightResponse.PreFightResponseFightData), + typeof(DateTimeOffset), typeof(int).MakeByRefType() ]); + DateTimeOffset allSchedulesOpenAt = DateTimeOffset.FromUnixTimeSeconds(1_742_983_200); PreFightResponse.PreFightResponseFightData fightData = new() { StageId = request.PreFightData.StageId }; - object?[] preFightArguments = [request.PreFightData, fightData, 0]; + object?[] preFightArguments = [request.PreFightData, fightData, allSchedulesOpenAt, 0]; AssertEqual(true, (bool)(applyPreFight.Invoke(null, preFightArguments) ?? false), "SimulateTrain pre-fight is recognized"); - AssertEqual(0, (int)(preFightArguments[2] ?? -1), + AssertEqual(0, (int)(preFightArguments[3] ?? -1), "SimulateTrain pre-fight is accepted"); AssertEqual?>(null, fightData.MonsterLevel, "SimulateTrain uses explicit NPC group instead of top-level monster levels"); @@ -107,10 +117,10 @@ private static void ValidateSimulateTrainCompatibility() PreFightResponse.PreFightResponseFightData officialCaptureFightData = new() { StageId = officialCaptureRequest.PreFightData.StageId }; object?[] officialCaptureArguments = - [officialCaptureRequest.PreFightData, officialCaptureFightData, 0]; + [officialCaptureRequest.PreFightData, officialCaptureFightData, allSchedulesOpenAt, 0]; AssertEqual(true, (bool)(applyPreFight.Invoke(null, officialCaptureArguments) ?? false), "Official captured SimulateTrain pre-fight is recognized"); - AssertEqual(0, (int)(officialCaptureArguments[2] ?? -1), + AssertEqual(0, (int)(officialCaptureArguments[3] ?? -1), "Official captured SimulateTrain pre-fight is accepted"); JObject officialWire = JObject.Parse(MessagePackSerializer.ConvertToJson( MessagePackSerializer.Serialize(new PreFightResponse @@ -140,51 +150,95 @@ private static void ValidateSimulateTrainCompatibility() [ request.PreFightData, new PreFightResponse.PreFightResponseFightData { StageId = request.PreFightData.StageId }, + allSchedulesOpenAt, 0 ]; AssertEqual(true, (bool)(applyPreFight.Invoke(null, unsupportedPeriodArguments) ?? false), "SimulateTrain unsupported period is recognized"); - AssertEqual(true, (int)(unsupportedPeriodArguments[2] ?? 0) != 0, + AssertEqual(true, (int)(unsupportedPeriodArguments[3] ?? 0) != 0, "SimulateTrain unsupported period is rejected"); request.PreFightData.SimulateTrainInfo.Period = 1; - SimulateTrainMonsterTable timeGatedBoss = bosses.Single(boss => boss.Id == 3_066); - PreFightRequest timeGatedRequest = new() + int PreFightCodeAt(SimulateTrainMonsterTable boss, DateTimeOffset now, int difficulty = 1) { - PreFightData = new() + PreFightRequest preFight = new() { - StageId = checked((uint)timeGatedBoss.StageId), - SimulateTrainInfo = new() + PreFightData = new() { - BossId = timeGatedBoss.Id, - Period = 1, - AtkLevel = 2, - HpLevel = 1, - Difficulty = 1, + StageId = checked((uint)boss.StageId), + SimulateTrainInfo = new() + { + BossId = boss.Id, + Period = 1, + AtkLevel = 2, + HpLevel = 1, + Difficulty = difficulty, + } } + }; + object?[] arguments = + [ + preFight.PreFightData, + new PreFightResponse.PreFightResponseFightData { StageId = preFight.PreFightData.StageId }, + now, + 0 + ]; + AssertEqual(true, (bool)(applyPreFight.Invoke(null, arguments) ?? false), + $"SimulateTrain boss {boss.Id} schedule check is recognized"); + return (int)(arguments[3] ?? -1); + } + + const long shorthaltStartTime = 1_742_810_400; + const long vonnegutStartTime = 1_742_983_200; + SimulateTrainMonsterTable shorthalt = bosses.Single(boss => boss.Id == 3_065); + SimulateTrainMonsterTable vonnegut = bosses.Single(boss => boss.Id == 3_066); + AssertEqual(36_303, shorthalt.TimeId, "SimulateTrain Shorthalt TimeId"); + AssertEqual(36_303, shorthalt.ImpasseTimeId, "SimulateTrain Shorthalt ImpasseTimeId"); + AssertEqual(36_304, vonnegut.TimeId, "SimulateTrain Vonnegut TimeId"); + AssertEqual(36_304, vonnegut.ImpasseTimeId, "SimulateTrain Vonnegut ImpasseTimeId"); + AssertEqual(true, + ActivityScheduleService.TryGet(shorthalt.TimeId, out ActivityScheduleEntry shorthaltSchedule), + "SimulateTrain Shorthalt schedule is configured"); + AssertEqual(true, + ActivityScheduleService.TryGet(vonnegut.TimeId, out ActivityScheduleEntry vonnegutSchedule), + "SimulateTrain Vonnegut schedule is configured"); + AssertEqual(shorthaltStartTime, shorthaltSchedule.StartTime, + "SimulateTrain Shorthalt authoritative start time"); + AssertEqual(vonnegutStartTime, vonnegutSchedule.StartTime, + "SimulateTrain Vonnegut authoritative start time"); + + DateTimeOffset shorthaltStart = DateTimeOffset.FromUnixTimeSeconds(shorthaltStartTime); + AssertEqual(20_003_024, PreFightCodeAt(shorthalt, shorthaltStart.AddSeconds(-1)), + "SimulateTrain Shorthalt is locked before its schedule"); + AssertEqual(0, PreFightCodeAt(shorthalt, shorthaltStart), + "SimulateTrain Shorthalt opens at its scheduled start"); + AssertEqual(20_003_024, PreFightCodeAt(vonnegut, shorthaltStart), + "SimulateTrain Vonnegut remains locked during Shorthalt's window"); + AssertEqual(0, PreFightCodeAt(vonnegut, DateTimeOffset.FromUnixTimeSeconds(vonnegutStartTime)), + "SimulateTrain Vonnegut opens at its scheduled start"); + foreach (SimulateTrainMonsterTable boss in bosses) + { + AssertEqual(0, PreFightCodeAt(boss, allSchedulesOpenAt), + $"SimulateTrain boss {boss.Id} base difficulty opens after its schedule"); + if (boss.ImpasseTimeId > 0) + { + AssertEqual(0, PreFightCodeAt(boss, allSchedulesOpenAt, boss.NpcId.Count), + $"SimulateTrain boss {boss.Id} Impasse difficulty opens after its schedule"); } - }; - object?[] timeGatedArguments = - [ - timeGatedRequest.PreFightData, - new PreFightResponse.PreFightResponseFightData { StageId = timeGatedRequest.PreFightData.StageId }, - 0 - ]; - AssertEqual(true, (bool)(applyPreFight.Invoke(null, timeGatedArguments) ?? false), - "SimulateTrain time-gated boss is recognized"); - AssertEqual(20_003_024, (int)(timeGatedArguments[2] ?? 0), - "SimulateTrain closed boss schedule is rejected"); + } + request.PreFightData.SimulateTrainInfo!.BossId = 9_999; object?[] invalidPreFightArguments = [ request.PreFightData, new PreFightResponse.PreFightResponseFightData { StageId = request.PreFightData.StageId }, + allSchedulesOpenAt, 0 ]; AssertEqual(true, (bool)(applyPreFight.Invoke(null, invalidPreFightArguments) ?? false), "SimulateTrain invalid pre-fight is recognized"); - AssertEqual(true, (int)(invalidPreFightArguments[2] ?? 0) != 0, + AssertEqual(true, (int)(invalidPreFightArguments[3] ?? 0) != 0, "SimulateTrain mismatched boss is rejected"); request.PreFightData.SimulateTrainInfo.BossId = 2_001; diff --git a/Resources/table/manifest.json b/Resources/table/manifest.json index 22f30cf2..e76bc297 100644 --- a/Resources/table/manifest.json +++ b/Resources/table/manifest.json @@ -1919,6 +1919,50 @@ "share/fuben/repeatchallenge/RepeatChallengeStage.json" ] }, + { + "columns": 2, + "contentSha256": "cb3af579d7e0613be446b054c55b2ef2a793706aa4fcd2faddbd4ffb5f6096da", + "filter": "all", + "naturalKey": "AtkLevel", + "path": "share/fuben/simulatetrain/SimulateTrainAtk.tsv", + "rows": 6, + "source": [ + "share/fuben/simulatetrain/SimulateTrainAtk.json" + ] + }, + { + "columns": 2, + "contentSha256": "e7f8dffd7eeb690e056feb1c68ad9ceff6286627dba44318fc03f8b37b02d351", + "filter": "all", + "naturalKey": "HpLevel", + "path": "share/fuben/simulatetrain/SimulateTrainHp.tsv", + "rows": 6, + "source": [ + "share/fuben/simulatetrain/SimulateTrainHp.json" + ] + }, + { + "columns": 16, + "contentSha256": "afc94bfe6215e9d050100297769dbbb45edee2a3ed468f8f904b483b1140be88", + "filter": "runtime-required-fields-preserving-authoritative-schedules", + "naturalKey": "Id", + "path": "share/fuben/simulatetrain/SimulateTrainMonster.tsv", + "rows": 76, + "source": [ + "share/fuben/simulatetrain/SimulateTrainMonster.json" + ] + }, + { + "columns": 3, + "contentSha256": "5a05f66dc3e1697997d8eb144fe78081f7eeb0fbcd3edf97995d31057710f52a", + "filter": "derived-nonzero-period-buffs", + "naturalKey": "BossId,Period", + "path": "share/fuben/simulatetrain/SimulateTrainPeriodBuff.tsv", + "rows": 22, + "source": [ + "share/fuben/simulatetrain/SimulateTrainMonster.json" + ] + }, { "columns": 39, "contentSha256": "0678fab4dbf984656e308f3d10682b6153fb0cd69cfaf0b81ddaf83dd412c1d6", @@ -3535,11 +3579,11 @@ }, { "columns": 4, - "contentSha256": "cbd78d683de651a9440f9ebe9bdcde53bcdfc50d16cb3567aa4c55b40d97a827", - "filter": "official-notice-feature-window-version-history-and-regional-client-entry-derived", + "contentSha256": "72289e51497cd1f223ac3f21136234aa216e143312233f89bf1f497428ea13af", + "filter": "official-notice-feature-window-version-history-regional-client-entry-retail-login-and-permanent-practice-derived", "naturalKey": "Id", "path": "share/activity/ActivitySchedule.tsv", - "rows": 34, + "rows": 47, "source": [ "share/activity/EventCatalog.tsv", "Resources/Configs/Notices/4.7.0/{GameNotice,LoginNotice}.json", @@ -3551,7 +3595,9 @@ "client/theatre6/Theatre6ClientConfig.json", "share/item/Item.json", "share/miniactivity/musicgame/concertpreheating/ConcertPreHeatingActivity.json", - "share/signin/SignIn.json" + "share/signin/SignIn.json", + "retail NotifyLogin capture:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf", + "SimulateTrain permanent practice:TimeId=201" ] } ], diff --git a/Resources/table/share/activity/ActivitySchedule.tsv b/Resources/table/share/activity/ActivitySchedule.tsv index 8a2e50b6..062dbc4e 100644 --- a/Resources/table/share/activity/ActivitySchedule.tsv +++ b/Resources/table/share/activity/ActivitySchedule.tsv @@ -2,6 +2,18 @@ Id StartTime EndTime Source 201 0 0 SimulateTrain:permanent-practice 716 1787115600 1787806740 version-history:EN-LIVE:4.6.6@bb3c34765c9d9c1c542079d536a17e82b27f3245/TransfiniteActivity+EN-LIVE:4.5.6@69465ac7168a8f33f517f2ffd6e366aa9f3c3357/TransfiniteActivity+LoginNotice:EndTime+GameNotice:update-note-EndTime+maintenance-duration+CycleSeconds 717 1787806800 1789016340 version-history:EN-LIVE:4.6.6@bb3c34765c9d9c1c542079d536a17e82b27f3245/TransfiniteActivity+EN-LIVE:4.5.6@69465ac7168a8f33f517f2ffd6e366aa9f3c3357/TransfiniteActivity+LoginNotice:EndTime+GameNotice:update-note-EndTime+maintenance-duration+CycleSeconds +30305 1725530400 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +30306 1725703200 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +30307 1725876000 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +30308 1726048800 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +32313 1729159200 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +32314 1729332000 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +32315 1729504800 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +32316 1729677600 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +36301 1742464800 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +36302 1742637600 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +36303 1742810400 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf +36304 1742983200 0 retail-NotifyLogin:0639@9301a037c5476c67b1d2792bfb6f2d62ffabbfaf 48032 1787115600 1790226000 version-history:EN-LIVE:4.6.6@bb3c34765c9d9c1c542079d536a17e82b27f3245/DrawCanLiverActivity+EN-LIVE:4.5.6@0236666432b01e5469a51047f4b112068fff8763/DrawCanLiverActivity+LoginNotice:EndTime+GameNotice:update-note-EndTime+maintenance-duration 48601 1784264400 1790139600 feature-window:Theatre6PvpActivity+Theatre6ClientConfig+Item:97090 48602 1784264400 1790139600 feature-window:Theatre6PvpActivity+Theatre6ClientConfig+Item:97090+Theatre6PvpRank:first-positive-TimeId diff --git a/Scripts/generate_current_simulate_train_tables.py b/Scripts/generate_current_simulate_train_tables.py index b21dabf0..93b026cd 100644 --- a/Scripts/generate_current_simulate_train_tables.py +++ b/Scripts/generate_current_simulate_train_tables.py @@ -144,8 +144,8 @@ def generate(source: Path) -> dict[str, bytes]: f"boss {boss_id}: NpcId, NpcLevel, and StageBuffId must have equal non-zero lengths") normalized_monsters.append(( boss_id, - integer(row, "TimeId", default=0), - integer(row, "ImpasseTimeId", default=0), + integer(row, "TimeId"), + integer(row, "ImpasseTimeId"), stage_id, npc_ids, npc_levels, From ba617af89e7757eba28d7400ff6f6e1cfc77dd28 Mon Sep 17 00:00:00 2001 From: VAGUE000 <45696542+VAGUE000@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:32:05 +0300 Subject: [PATCH 3/3] Make SimulateTrain schedule regression table-driven --- .../Program.SimulateTrainCompatibility.cs | 77 +++++++++++-------- 1 file changed, 43 insertions(+), 34 deletions(-) diff --git a/AscNet.Test/Program.SimulateTrainCompatibility.cs b/AscNet.Test/Program.SimulateTrainCompatibility.cs index c60e8068..e9e39fe8 100644 --- a/AscNet.Test/Program.SimulateTrainCompatibility.cs +++ b/AscNet.Test/Program.SimulateTrainCompatibility.cs @@ -29,14 +29,18 @@ private static void ValidateSimulateTrainCompatibility() AssertEqual(0, ronin.ImpasseTimeId, "SimulateTrain Ronin ImpasseTimeId"); AssertEqual(true, ActivityScheduleService.IsOpen(ronin.TimeId, DateTimeOffset.UtcNow), "SimulateTrain permanent practice schedule is open"); + Dictionary simulateTrainSchedules = []; foreach (int timeId in bosses .SelectMany(boss => new[] { boss.TimeId, boss.ImpasseTimeId }) .Where(timeId => timeId > 0) .Distinct()) { - AssertEqual(true, ActivityScheduleService.TryGet(timeId, out _), + AssertEqual(true, ActivityScheduleService.TryGet(timeId, out ActivityScheduleEntry schedule), $"SimulateTrain TimeId {timeId} has an authoritative schedule"); + simulateTrainSchedules[timeId] = schedule; } + DateTimeOffset allSchedulesOpenAt = DateTimeOffset.FromUnixTimeSeconds( + simulateTrainSchedules.Values.Max(schedule => schedule.StartTime)); List periodBuffs = TableReaderV2.Parse(); AssertEqual(22, periodBuffs.Count, "SimulateTrain period buff row count"); @@ -74,7 +78,6 @@ private static void ValidateSimulateTrainCompatibility() typeof(DateTimeOffset), typeof(int).MakeByRefType() ]); - DateTimeOffset allSchedulesOpenAt = DateTimeOffset.FromUnixTimeSeconds(1_742_983_200); PreFightResponse.PreFightResponseFightData fightData = new() { StageId = request.PreFightData.StageId }; object?[] preFightArguments = [request.PreFightData, fightData, allSchedulesOpenAt, 0]; AssertEqual(true, (bool)(applyPreFight.Invoke(null, preFightArguments) ?? false), @@ -188,46 +191,52 @@ int PreFightCodeAt(SimulateTrainMonsterTable boss, DateTimeOffset now, int diffi return (int)(arguments[3] ?? -1); } - const long shorthaltStartTime = 1_742_810_400; - const long vonnegutStartTime = 1_742_983_200; - SimulateTrainMonsterTable shorthalt = bosses.Single(boss => boss.Id == 3_065); - SimulateTrainMonsterTable vonnegut = bosses.Single(boss => boss.Id == 3_066); - AssertEqual(36_303, shorthalt.TimeId, "SimulateTrain Shorthalt TimeId"); - AssertEqual(36_303, shorthalt.ImpasseTimeId, "SimulateTrain Shorthalt ImpasseTimeId"); - AssertEqual(36_304, vonnegut.TimeId, "SimulateTrain Vonnegut TimeId"); - AssertEqual(36_304, vonnegut.ImpasseTimeId, "SimulateTrain Vonnegut ImpasseTimeId"); - AssertEqual(true, - ActivityScheduleService.TryGet(shorthalt.TimeId, out ActivityScheduleEntry shorthaltSchedule), - "SimulateTrain Shorthalt schedule is configured"); - AssertEqual(true, - ActivityScheduleService.TryGet(vonnegut.TimeId, out ActivityScheduleEntry vonnegutSchedule), - "SimulateTrain Vonnegut schedule is configured"); - AssertEqual(shorthaltStartTime, shorthaltSchedule.StartTime, - "SimulateTrain Shorthalt authoritative start time"); - AssertEqual(vonnegutStartTime, vonnegutSchedule.StartTime, - "SimulateTrain Vonnegut authoritative start time"); + void AssertScheduleWindow( + SimulateTrainMonsterTable boss, + int difficulty, + string mode, + params ActivityScheduleEntry[] schedules) + { + long opensAt = schedules.Max(schedule => schedule.StartTime); + long closesAt = schedules + .Select(schedule => schedule.EndTime) + .Where(time => time > 0) + .DefaultIfEmpty() + .Min(); + int CodeAt(long time) => PreFightCodeAt( + boss, + DateTimeOffset.FromUnixTimeSeconds(time), + difficulty); + + if (opensAt > 0) + { + AssertEqual(20_003_024, CodeAt(opensAt - 1), + $"SimulateTrain boss {boss.Id} {mode} is locked before its schedule"); + } + AssertEqual(0, CodeAt(opensAt), + $"SimulateTrain boss {boss.Id} {mode} opens at its schedule"); + if (closesAt > 0) + { + AssertEqual(20_003_024, CodeAt(closesAt), + $"SimulateTrain boss {boss.Id} {mode} locks when its schedule ends"); + } + } - DateTimeOffset shorthaltStart = DateTimeOffset.FromUnixTimeSeconds(shorthaltStartTime); - AssertEqual(20_003_024, PreFightCodeAt(shorthalt, shorthaltStart.AddSeconds(-1)), - "SimulateTrain Shorthalt is locked before its schedule"); - AssertEqual(0, PreFightCodeAt(shorthalt, shorthaltStart), - "SimulateTrain Shorthalt opens at its scheduled start"); - AssertEqual(20_003_024, PreFightCodeAt(vonnegut, shorthaltStart), - "SimulateTrain Vonnegut remains locked during Shorthalt's window"); - AssertEqual(0, PreFightCodeAt(vonnegut, DateTimeOffset.FromUnixTimeSeconds(vonnegutStartTime)), - "SimulateTrain Vonnegut opens at its scheduled start"); foreach (SimulateTrainMonsterTable boss in bosses) { - AssertEqual(0, PreFightCodeAt(boss, allSchedulesOpenAt), - $"SimulateTrain boss {boss.Id} base difficulty opens after its schedule"); + ActivityScheduleEntry baseSchedule = simulateTrainSchedules[boss.TimeId]; + AssertScheduleWindow(boss, 1, "base", baseSchedule); if (boss.ImpasseTimeId > 0) { - AssertEqual(0, PreFightCodeAt(boss, allSchedulesOpenAt, boss.NpcId.Count), - $"SimulateTrain boss {boss.Id} Impasse difficulty opens after its schedule"); + AssertScheduleWindow( + boss, + boss.NpcId.Count, + "Impasse", + baseSchedule, + simulateTrainSchedules[boss.ImpasseTimeId]); } } - request.PreFightData.SimulateTrainInfo!.BossId = 9_999; object?[] invalidPreFightArguments = [