From 0fd4f5f9f72c8eefd14db9788ac5237cfe8842b3 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 31 May 2026 22:15:44 -0400 Subject: [PATCH] feat(battle-node): ScriptedLifecycle frame builders (Path-A static opponent) --- .../Lifecycle/ScriptedLifecycle.cs | 162 ++++++++++++++++++ .../Lifecycle/ScriptedLifecycleTests.cs | 63 +++++++ 2 files changed, 225 insertions(+) create mode 100644 SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs create mode 100644 SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs diff --git a/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs new file mode 100644 index 0000000..47ec22c --- /dev/null +++ b/SVSim.BattleNode/Lifecycle/ScriptedLifecycle.cs @@ -0,0 +1,162 @@ +using SVSim.BattleNode.Protocol; + +namespace SVSim.BattleNode.Lifecycle; + +/// +/// v1 Path-A scripted opponent. Hand-rolled static frames good enough to land the client on +/// the mulligan screen and let them play turn 1. Templates derived from +/// data_dumps/captures/battle-traffic_tk2_regular.ndjson. +/// +public static class ScriptedLifecycle +{ + /// 30 dummy cardIds — repeats of a stable neutral card. + public static readonly long DummyCardId = 100011010; + + public const long FakeOpponentViewerId = 999_999_999L; + + public static MsgEnvelope BuildMatched(long playerViewerId, long opponentViewerId, string battleId) + { + var body = new Dictionary + { + ["selfInfo"] = new Dictionary + { + ["country_code"] = "KOR", + ["userName"] = "Player", + ["sleeveId"] = "3000011", + ["emblemId"] = "701441011", + ["degreeId"] = "300003", + ["fieldId"] = 43, + ["isOfficial"] = 0, + ["oppoId"] = opponentViewerId, + ["seed"] = 17548138L, + }, + ["oppoInfo"] = new Dictionary + { + ["country_code"] = "JPN", + ["userName"] = "Opponent", + ["sleeveId"] = "704141010", + ["emblemId"] = "400001100", + ["degreeId"] = "120027", + ["fieldId"] = 5, + ["isOfficial"] = 0, + ["oppoId"] = playerViewerId, + ["seed"] = 17548138L, + ["oppoDeckCount"] = 30, + }, + ["selfDeck"] = BuildDummyDeck(), + }; + return EnvelopeForPush(NetworkBattleUri.Matched, body, bid: battleId); + } + + public static MsgEnvelope BuildBattleStart(long playerViewerId) + { + var body = new Dictionary + { + ["turnState"] = 0, // player goes first + ["battleType"] = 11, // TK2 NetworkBattleType + ["selfInfo"] = new Dictionary + { + ["rank"] = "10", + ["battlePoint"] = "6270", + ["classId"] = "1", + ["charaId"] = "1", + ["cardMasterName"] = "card_master_node_10015", + }, + ["oppoInfo"] = new Dictionary + { + ["rank"] = "1", + ["isMasterRank"] = "0", + ["battlePoint"] = 0, + ["masterPoint"] = "0", + ["classId"] = "8", + ["charaId"] = "8", + ["cardMasterName"] = "card_master_node_10015", + }, + }; + return EnvelopeForPush(NetworkBattleUri.BattleStart, body); + } + + public static MsgEnvelope BuildDeal() + { + var body = new Dictionary + { + ["self"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["pos"] = 2, ["idx"] = 3 }, + }, + ["oppo"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["pos"] = 2, ["idx"] = 3 }, + }, + }; + return EnvelopeForPush(NetworkBattleUri.Deal, body); + } + + public static MsgEnvelope BuildSwapResponse(IReadOnlyList swapIndices) + { + // v1: ignore the player's chosen indices and echo the same hand back. + // (Acceptable because the client doesn't validate which idxs come back — it just renders them.) + _ = swapIndices; + var body = new Dictionary + { + ["self"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["pos"] = 2, ["idx"] = 3 }, + }, + }; + return EnvelopeForPush(NetworkBattleUri.Swap, body); + } + + public static MsgEnvelope BuildReady() + { + var body = new Dictionary + { + ["self"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["pos"] = 2, ["idx"] = 3 }, + }, + ["oppo"] = new List + { + new Dictionary { ["pos"] = 0, ["idx"] = 1 }, + new Dictionary { ["pos"] = 1, ["idx"] = 2 }, + new Dictionary { ["pos"] = 2, ["idx"] = 3 }, + }, + ["idxChangeSeed"] = 771335280, + ["spin"] = 243, + }; + return EnvelopeForPush(NetworkBattleUri.Ready, body); + } + + private static List BuildDummyDeck() + { + var deck = new List(30); + for (var i = 1; i <= 30; i++) + { + deck.Add(new Dictionary + { + ["idx"] = i, + ["cardId"] = DummyCardId, + }); + } + return deck; + } + + private static MsgEnvelope EnvelopeForPush(NetworkBattleUri uri, Dictionary body, string? bid = null) => + new(uri, + ViewerId: FakeOpponentViewerId, + Uuid: "node-stub", + Bid: bid, + Try: 0, + Cat: EmitCategory.Battle, + PubSeq: null, + PlaySeq: null, // OutboundSequencer.AssignAndArchive stamps this + Body: body); +} diff --git a/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs new file mode 100644 index 0000000..2387f30 --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Lifecycle/ScriptedLifecycleTests.cs @@ -0,0 +1,63 @@ +using NUnit.Framework; +using SVSim.BattleNode.Lifecycle; +using SVSim.BattleNode.Protocol; + +namespace SVSim.UnitTests.BattleNode.Lifecycle; + +[TestFixture] +public class ScriptedLifecycleTests +{ + [Test] + public void BuildMatched_PutsOppoIdInSelfInfoEqualToTheRealOpponentVid() + { + var env = ScriptedLifecycle.BuildMatched(playerViewerId: 906243102, opponentViewerId: 847666884, battleId: "b"); + + Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched)); + var selfInfo = (Dictionary)env.Body["selfInfo"]!; + Assert.That(selfInfo["oppoId"], Is.EqualTo(847666884L)); + var oppoInfo = (Dictionary)env.Body["oppoInfo"]!; + Assert.That(oppoInfo["oppoId"], Is.EqualTo(906243102L)); + } + + [Test] + public void BuildMatched_ContainsThirtyCardSelfDeck() + { + var env = ScriptedLifecycle.BuildMatched(1, 2, "b"); + var deck = (List)env.Body["selfDeck"]!; + Assert.That(deck.Count, Is.EqualTo(30)); + } + + [Test] + public void BuildBattleStart_HasTurnStateZeroAndBattleTypeEleven() + { + var env = ScriptedLifecycle.BuildBattleStart(playerViewerId: 1); + Assert.That(env.Body["turnState"], Is.EqualTo(0)); + Assert.That(env.Body["battleType"], Is.EqualTo(11)); + } + + [Test] + public void BuildDeal_HasThreeSelfAndThreeOppoEntries() + { + var env = ScriptedLifecycle.BuildDeal(); + var self = (List)env.Body["self"]!; + var oppo = (List)env.Body["oppo"]!; + Assert.That(self.Count, Is.EqualTo(3)); + Assert.That(oppo.Count, Is.EqualTo(3)); + } + + [Test] + public void BuildSwapResponse_EchoesSameHandIfNoSwap() + { + var env = ScriptedLifecycle.BuildSwapResponse(swapIndices: Array.Empty()); + var self = (List)env.Body["self"]!; + Assert.That(self.Count, Is.EqualTo(3)); + } + + [Test] + public void BuildReady_IncludesIdxChangeSeedAndSpin() + { + var env = ScriptedLifecycle.BuildReady(); + Assert.That(env.Body.ContainsKey("idxChangeSeed"), Is.True); + Assert.That(env.Body.ContainsKey("spin"), Is.True); + } +}