using SVSim.BattleNode.Protocol; namespace SVSim.BattleNode.Lifecycle; /// /// v1 hand-rolled scripted opponent. Static frame builders for the five lifecycle uris /// (Matched / BattleStart / Deal / Swap response / Ready) plus a trivial opponent TurnStart /// the dispatch pushes after the player's TurnEnd. The values are templated from the TK2 /// captures at data_dumps/captures/battle-traffic_tk2_regular.ndjson — anything /// hardcoded here came from a real prod frame. /// /// /// "Scripted" means the opponent never reacts to your plays. We push enough to land /// you on the mulligan screen, run a real mulligan exchange, give you turn 1, transition /// to "Opponent's turn…" after your TurnEnd, and then sit there indefinitely. This /// is the documented v1 stopping point. /// /// All builders go through , which injects /// resultCode = 1 into every body. The client's OnReceived drops any /// synchronize push whose resultCode != Success (absent counts as None=0); leaving /// it off silently breaks the state machine without surfacing an error. /// /// To make this less scripted: see the project README §"Where to extend". /// public static class ScriptedLifecycle { /// /// CardId used for all 30 entries in the dummy deck. A stable neutral card that exists in /// every card-master version we care about, so the client can render it without /// triggering a card-master-mismatch error. /// public static readonly long DummyCardId = 100011010; /// /// Viewer id we present as the opponent on every scripted push. Out-of-range vs. real /// viewer ids so it can't collide with a real account in the auth pipeline. /// 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); } /// /// Initial 3-card hand idxs from . Each position in this array /// is one card; the value is the card's deck idx. /// private static readonly long[] InitialHand = { 1, 2, 3 }; /// /// Compute the player's hand after a mulligan. For every idx in /// that is currently in the hand, replace it with the next unused deck idx (starting at 4, /// since 1..3 were dealt). Positions of kept cards are preserved. /// public static long[] ComputeHandAfterSwap(IReadOnlyList swapIndices) { var hand = (long[])InitialHand.Clone(); var nextDeckIdx = 4L; for (var pos = 0; pos < hand.Length; pos++) { if (swapIndices.Contains(hand[pos])) { hand[pos] = nextDeckIdx++; } } return hand; } public static MsgEnvelope BuildSwapResponse(IReadOnlyList hand) { var body = new Dictionary { ["self"] = BuildPosIdxList(hand), }; return EnvelopeForPush(NetworkBattleUri.Swap, body); } public static MsgEnvelope BuildReady(IReadOnlyList hand) { var body = new Dictionary { ["self"] = BuildPosIdxList(hand), // Opponent hand stays at the static 3 cards for v1. ["oppo"] = BuildPosIdxList(InitialHand), ["idxChangeSeed"] = 771335280, ["spin"] = 243, }; return EnvelopeForPush(NetworkBattleUri.Ready, body); } /// /// Generic TurnStart push used to transition the client into "Opponent's turn…" state /// after the player's TurnEnd. v1 doesn't simulate the opponent — once this lands the /// client sits at the opponent-turn display indefinitely. /// public static MsgEnvelope BuildOpponentTurnStart() { var body = new Dictionary { ["spin"] = 100, }; return EnvelopeForPush(NetworkBattleUri.TurnStart, body); } private static List BuildPosIdxList(IReadOnlyList hand) { var list = new List(hand.Count); for (var pos = 0; pos < hand.Count; pos++) { list.Add(new Dictionary { ["pos"] = pos, ["idx"] = (int)hand[pos] }); } return list; } 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) { // Synchronize-push routing in the client's OnReceived drops any frame whose // resultCode != Success (1). Absent counts as 0 (None) and is also dropped — so we // MUST include it on every scripted push, not just InitNetwork ack / BattleFinish. // See server-to-client.md §"Routing in OnReceived" and the matching prod captures. body["resultCode"] = (int)ReceiveNodeResultCode.Success; return new MsgEnvelope(uri, ViewerId: FakeOpponentViewerId, Uuid: "node-stub", Bid: bid, Try: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, // OutboundSequencer.AssignAndArchive stamps this Body: body); } }