diff --git a/SVSim.BattleNode/README.md b/SVSim.BattleNode/README.md index 4356175..733d02c 100644 --- a/SVSim.BattleNode/README.md +++ b/SVSim.BattleNode/README.md @@ -1,142 +1,21 @@ # SVSim.BattleNode -Socket.IO node-server scaffolding for in-battle traffic. Implements the second of the prod 4-server topology — the realtime channel that handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` / `TurnEnd` between the client and a server-side opponent. +Socket.IO node-server emulation for in-battle real-time traffic — the second of prod's 4-server +topology. Handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` / +`TurnEnd` between a client and a server-side opponent, for TK2 PvP and AI rank battles. -**v1 scope** is "scripted thin sequencer": the server accepts a connection, walks a hand-rolled lifecycle from `InitNetwork` to mulligan + first turn + opponent TurnStart, then sits at the opponent's-turn screen indefinitely. No real opponent, no `battleCode` validation, no recovery. v2 work targets each of those. +## Documentation lives in the outer repo -The library has **no dependency on `SVSim.EmulatedEntrypoint`**. It exposes one DI seam (`IMatchingBridge`) and one ASP.NET Core integration surface (`AddBattleNode` / `UseBattleNode`). Pulling the node into a separate process later is one interface and one Kestrel binding. +This project's canonical reference is a single hub doc in the **outer** SVSim repo (this directory +is an inner git repo, so the doc isn't tracked alongside the code): -## Architecture +→ **`docs/battle-node.md`** (from the SVSim root) — architecture, the dispatch matrix by battle +type, connect handshake + crypto, `BattleFinish` wire-result semantics, SIO/EIO event coverage, +reliability (pubSeq/playSeq/Gungnir), wire-format gotchas, where-to-extend, the manual smoke +walkthrough, and the consolidated open-items list. -``` -SVSim.BattleNode/ -├─ Bridge/ IMatchingBridge — what /do_matching calls to mint a battle id + node URL -├─ Hosting/ ASP.NET Core extensions + the /socket.io/ endpoint handler -├─ Lifecycle/ ScriptedLifecycle — the v1 hand-rolled Matched/BattleStart/Deal/Swap/Ready frames -├─ Protocol/ MsgEnvelope, NetworkBattleUri enum, msgpack ↔ envelope codec -├─ Reliability/ InboundTracker (pubSeq dedup), OutboundSequencer (playSeq archive), Gungnir -├─ Sessions/ BattleSession (per-connection state + WS pump), IBattleSessionStore -└─ Wire/ EIO3 framing, SIO2 framing, NodeCrypto (AES-256-CBC) -``` +Relative path from here: [`../../../docs/battle-node.md`](../../../docs/battle-node.md). -## Connect handshake (verified end-to-end against the real client) +Detailed per-URI wire shapes are in `docs/api-spec/in-battle/`; the hub links into them. -``` -┌────────┐ ┌────────────┐ -│ Client │ │ BattleNode │ -└────┬───┘ └──────┬─────┘ - │ │ - │ HTTP POST /arena_two_pick_battle/do_matching │ (HTTP host) - ├──────────────────────────────────────────────────────────────►│ - │ ◄── { matching_state:3004, battle_id, node_server_url, │ - │ card_master_id, ... } │ - │ │ - │ WS upgrade ws:///socket.io/ │ - │ headers: BattleId, viewerId=encryptForNode(uid) │ - ├──────────────────────────────────────────────────────────────►│ AcceptWebSocketAsync - │ ◄── EIO3 Open 0{sid,upgrades:[],pingInterval,pingTimeout} │ - │ │ - │ msg: InitNetwork (cat=99/general) │ - ├──────────────────────────────────────────────────────────────►│ - │ ◄── synchronize: InitNetwork{resultCode:1} │ - │ │ - │ MatchingInitBattle: status=Connect; subscribe receiver │ - │ msg: InitBattle (cat=2/matching) │ - ├──────────────────────────────────────────────────────────────►│ - │ ◄── synchronize: Matched{selfInfo,oppoInfo,selfDeck,bid} │ - │ │ - │ client loads decks/scene │ - │ msg: Loaded │ - ├──────────────────────────────────────────────────────────────►│ - │ ◄── synchronize: BattleStart{turnState,battleType,...} │ - │ ◄── synchronize: Deal{self,oppo} │ - │ │ - │ mulligan UI; player chooses cards to swap │ - │ msg: Swap{idxList:[...]} │ - ├──────────────────────────────────────────────────────────────►│ - │ ◄── synchronize: Swap{self:[post-mulligan hand]} │ - │ ◄── synchronize: Ready{self,oppo,idxChangeSeed,spin} │ - │ │ - │ turn 1: TurnStart, PlayActions, ..., TurnEnd │ - ├──────────────────────────────────────────────────────────────►│ - │ ◄── synchronize: TurnStart{spin} (opponent turn signal) │ - │ │ - │ sits at "Opponent's turn…" — v1 stopping point │ -``` - -Each push from us carries a contiguous `playSeq`; client-emit `pubSeq` is echoed back via the Socket.IO ack callback. `Gungnir` runs a 5s alive heartbeat in parallel reporting `scs:ONLINE,ocs:ONLINE`. - -## Wire-format gotchas (discovered during v1 smoke) - -These are not in the original protocol docs and tripped us during the smoke walkthrough — leaving them here so the next reader doesn't repeat the diagnosis. - -| Spec said | Actual wire | Where it shows up | -|---|---|---| -| `AdditionalQueryParams` on the WS upgrade | **HTTP request headers**, not query string. BestHTTP misnames the API. | `BattleNodeWebSocketHandler.ReadCredential` reads `BattleId` / `viewerId` from headers first, query as fallback (for tests). | -| `node_server_url` ws://host:port | `host:port/socket.io/` — **no scheme prefix**, **path included**. | `BattleNodeOptions.NodeServerUrl` default + `do_matching` response. | -| `card_master_id` optional | **Required** when `matching_state ∈ {3004,3007,3011}` — no `Keys.Contains` guard client-side. | Added to `DoMatchingResponseDto` with default `1`. | -| `resultCode` optional on pushes | **Required = 1** on every scripted synchronize frame; missing means "drop in error handler". | `ScriptedLifecycle.EnvelopeForPush` injects it. | -| Matched in response to InitNetwork | **InitBattle**. Matched in response to InitNetwork lands before the client's matching handler is subscribed and silently drops. | See dispatch in `BattleSession.ComputeResponses`. | -| WS binary frames carry raw msgpack | EIO3 prefixes binary frames with `0x04` (Message type byte), same as the leading digit on text frames. | `BattleSession.RunAsync` strips on read; `EncodeAndSendAsync` prepends on send. | - -There's also a JSON parsing pitfall worth knowing about (and that broke the mulligan): the inline conditional `el.TryGetInt64(out var l) ? l : el.GetDouble()` unifies its branches to the common implicit-convertible type. Since `long → double` is implicit, the long silently widens to double, and `OfType` downstream drops every entry. See `MsgEnvelope.ParseNumber` for the fix — keep number parsing in a separate method so each branch boxes its own runtime type. - -## v1 scripted opponent — what the client sees - -The player half of `Matched` / `BattleStart` reads from a `MatchContext` assembled in -`SVSim.EmulatedEntrypoint/Services/MatchContextBuilder` from the viewer's TK2 run + equipped -cosmetics + config — so the mulligan renders the real drafted deck, drafted class/leader, -and equipped emblem/degree. The opponent half stays scripted in `ScriptedProfiles`: - -- **Opponent** is a fixed silhouette: `classId="8"`, JPN sleeve/emblem/degree, viewer id `999999999`. -- **Battle seed** is `17548138L` in both info blocks (the seed is *shared* per battle per the spec). -- **Mulligan** does real card replacement: any idx in your `idxList` is swapped for the next unused deck idx (`1..3` dealt, so `4..30` are pool). -- **Opponent's turn** never actually does anything — we push a single `TurnStart{spin:100}` after your `TurnEnd` so the UI transitions to the opponent-turn display, then sit. - -A few player-side fields are still hardcoded pending a follow-up slice — `Rank`, `BattlePoint`, -`cardMasterName`, `fieldId`, and the per-battle RNG seed. See the spec's §Deferred plumbing -table at `docs/superpowers/specs/2026-06-01-battle-node-real-drafted-deck-design.md` for -what each needs. - -## Where to extend - -| You want to | Touch | -|---|---| -| Wire a new mode's `do_matching` (rank, free, open-room, …) | Add one `BuildForAsync(viewerId, …)` method to `IMatchContextBuilder` reading that mode's deck source; the mode's controller calls `IMatchingBridge.RegisterPendingBattle(vid, ctx)`. No changes to `SVSim.BattleNode`. | -| Add a real AI opponent | Replace the static dispatch in `BattleSession.ComputeResponses` (`TurnEnd → opponent TurnStart` case) with one that drives a decision engine. The `OutboundSequencer` already assigns `playSeq` for whatever you push. | -| Implement recovery | `IBattleSessionStore` already keeps the pending registry. Add a per-battle archive (the `OutboundSequencer.Archive` already retains every assigned-playSeq push) and bind it to the HTTP `/battle/get_recovery_params` endpoint. | -| Validate `battleCode` | Port `NetworkConsistency.GetConsistency` from the client decompilation. Hook into `BattleSession.HandleMsgEventAsync` on `TurnEnd` / `Judge`. | -| Type the `orderList` register actions | Spec at `docs/api-spec/in-battle/register-actions.md` catalogs the eight shapes observed in TK2 captures. Build a discriminated union; replace `Dictionary` in the `Body` for the relevant URIs. | - -## Test layout - -``` -SVSim.UnitTests/BattleNode/ -├─ Bridge/ MatchingBridgeTests (3 tests — mint id, dedup, format) -├─ Integration/ BattleNodeFlowTests (end-to-end via WebApplicationFactory) -│ RawSocketIoTestClient (test helper) -├─ Lifecycle/ ScriptedLifecycleTests (11 tests) -├─ Protocol/ MsgEnvelopeTests (4 tests incl. number-array regression) -│ MsgPayloadCodecTests (2 tests — roundtrip + known vector) -├─ Reliability/ GungnirTests / InboundTrackerTests / OutboundSequencerTests -├─ Sessions/ BattleSessionDispatchTests (8 tests — phase-state machine) -│ InMemoryBattleSessionStoreTests -└─ Wire/ NodeCryptoTests (with fixed-vector regression) - EngineIoFrameTests - SocketIoFrameTests (incl. binary attachment + JSON escaping) -``` - -Total ~71 BattleNode-scoped tests. The integration test boots the EmulatedEntrypoint host via `SVSimTestFactory`, mints a battle through `IMatchingBridge`, opens a TestServer WebSocket, and walks the full handshake through Ready. It exercises every layer. - -## Related docs - -- `docs/api-spec/in-battle/transport.md` — Socket.IO + AES-for-node wire format, with smoke corrections inline. -- `docs/api-spec/in-battle/matching.md` — `do_matching` bridge + client state machine. -- `docs/api-spec/in-battle/server-to-client.md`, `client-to-server.md` — per-uri frame shapes. -- `docs/api-spec/in-battle/register-actions.md` — `orderList` action catalog (for v2). -- `docs/api-spec/in-battle/reliability.md` — pubSeq/playSeq stocking + Gungnir. -- `docs/api-spec/in-battle/recovery.md` — the reconnect handshake (deferred to v2). -- `docs/operations/battle-node-smoke.md` — manual end-to-end checklist. -- `docs/operations/battle-node-smoke-walkthrough.md` — annotated walkthrough with per-step diagnostics. -- `docs/superpowers/specs/2026-05-31-battle-node-transport-design.md` — v1 design. -- `docs/superpowers/plans/2026-05-31-battle-node-transport.md` — v1 implementation plan. +Keep `docs/battle-node.md` updated in the same change whenever you alter node behavior. diff --git a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs new file mode 100644 index 0000000..bdc97aa --- /dev/null +++ b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs @@ -0,0 +1,300 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using SVSim.BattleNode.Bridge; +using SVSim.BattleNode.Protocol; +using SVSim.BattleNode.Wire; +using SVSim.UnitTests.Infrastructure; + +namespace SVSim.UnitTests.BattleNode.Integration; + +/// +/// Wire-shape conformance of our server-authored synchronize frames against real prod TK2 +/// captures (data_dumps/captures/battle-traffic_tk2_regular.ndjson + +/// …_tk2_second.ndjson, captured 2026-05-31 from a real client mid-PvP). +/// +/// What this guards: for every frame our server *authors* (as opposed to forwarding a +/// client's bytes), the payload it emits must carry every key prod sent, with a matching value +/// *category* (object / array / string / number / bool). This is the bug class that has bitten the +/// node repeatedly — wrong casing (card_id vs cardID), a missing field the client +/// reads without a guard, or a string where the client expects a number. The existing +/// assert frame *ordering and routing*; they never inspect the +/// body. This closes that gap and turns the prod captures into a permanent regression oracle that +/// survives the June-2026 server shutdown. +/// +/// Direction of the check is capture ⊆ ours — we must emit at least what prod emits +/// (missing/miscased/mistyped = fail), but we may emit extra envelope fields (we send +/// viewerId/uuid/try/cat on pushes; prod's receive frames omit them). Pure +/// envelope/sequencing keys (viewerId, uuid, try, cat, bid, pubSeq, playSeq) are excluded +/// from the comparison: they're transport concerns assigned by the sequencer, covered by the +/// reliability layer + integration tests, and legitimately vary per frame (e.g. the no-stock +/// BattleFinish frame is played immediately whether or not it carries a playSeq). +/// The check is on *body shape*. +/// +/// Coverage: a single Scripted session emits all ten server-authored URIs +/// (InitNetwork, Matched, BattleStart, Deal, Swap, Ready, TurnStart, TurnEnd, Judge, +/// BattleFinish). PvP uses the same +/// builders for the handshake/mulligan frames, so this transitively covers the PvP handshake shape +/// too. Forwarded frames (PlayActions / TurnEndActions / ChatStamp / TurnEndFinal) relay the +/// client's own bytes verbatim, so their shape is the client's contract, not ours — out of scope +/// here. +/// +[TestFixture] +public class CaptureConformanceTests +{ + private const long ViewerId = 906243102L; + + // Top-level keys that are envelope/transport, not body shape. Excluded from the comparison + // at the root level only (nested objects never contain these). + private static readonly HashSet IgnoredEnvelopeKeys = new() + { + "viewerId", "uuid", "try", "cat", "bid", "pubSeq", "playSeq", + }; + + [Test] + [Timeout(30000)] + public async Task ServerAuthoredFrames_MatchProdCaptureShapes() + { + await using var factory = new SVSimTestFactory(); + var bridge = factory.Services.GetRequiredService(); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); + var ct = cts.Token; + + var pending = bridge.RegisterBattle( + new BattlePlayer(ViewerId, BattleNodeFlowTests.FixtureCtx()), + p2: null, + SVSim.BattleNode.Sessions.BattleType.Scripted); + + var key = MakeKey(); + var encryptedVid = NodeCrypto.EncryptForNode(ViewerId.ToString(), key); + var wsUri = new Uri( + $"ws://localhost/socket.io/?BattleId={pending.BattleId}&viewerId={Uri.EscapeDataString(encryptedVid)}&EIO=3&transport=websocket"); + + var wsClient = factory.Server.CreateWebSocketClient(); + var ws = await wsClient.ConnectAsync(wsUri, ct); + await using var client = new RawSocketIoTestClient(ws); + await client.ConsumeHandshakeAsync(ct); + + // Drive the full Scripted lifecycle, harvesting every server-pushed frame by URI. + var harvested = new Dictionary(); + + async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes, + Dictionary? body = null) + { + await client.SendMsgAsync(MakeEnvelope(send, pubSeq, body), key, ct); + for (var i = 0; i < expectPushes; i++) + { + var frame = await client.ReceiveSynchronizeAsync(ct); + harvested[frame.Uri] = frame; + } + } + + await DriveAsync(NetworkBattleUri.InitNetwork, 1, expectPushes: 1); + await DriveAsync(NetworkBattleUri.InitBattle, 2, expectPushes: 1); // Matched + await DriveAsync(NetworkBattleUri.Loaded, 3, expectPushes: 2); // BattleStart + Deal + await DriveAsync(NetworkBattleUri.Swap, 4, expectPushes: 2, // Swap + Ready + body: new Dictionary { ["idxList"] = new List() }); + await DriveAsync(NetworkBattleUri.TurnEnd, 5, expectPushes: 3); // TurnStart + TurnEnd + Judge + await DriveAsync(NetworkBattleUri.Retire, 6, expectPushes: 1); // BattleFinish + + // Compare each harvested frame's wire JSON against the prod capture fixture. + using var fixtures = JsonDocument.Parse(ProdCaptureFixture.Json); + var failures = new List(); + + foreach (var uriName in ExpectedUris) + { + var uri = Enum.Parse(uriName); + if (!harvested.TryGetValue(uri, out var env)) + { + failures.Add($"[{uriName}] our server never pushed this frame during the Scripted lifecycle."); + continue; + } + + var expected = fixtures.RootElement.GetProperty(uriName); + using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); + CompareSubset(expected, ourDoc.RootElement, uriName, isRoot: true, failures); + } + + if (failures.Count > 0) + { + Assert.Fail( + "Server-authored frames diverge from the prod TK2 capture shapes:\n - " + + string.Join("\n - ", failures)); + } + } + + private static readonly string[] ExpectedUris = + { + "InitNetwork", "Matched", "BattleStart", "Deal", "Swap", "Ready", + "TurnStart", "TurnEnd", "Judge", "BattleFinish", + }; + + /// + /// Recursively assert every key/element in (the prod capture) + /// exists in (our wire JSON) with a matching value category. + /// + private static void CompareSubset(JsonElement expected, JsonElement actual, string path, + bool isRoot, List failures) + { + switch (expected.ValueKind) + { + case JsonValueKind.Object: + if (actual.ValueKind != JsonValueKind.Object) + { + failures.Add($"{path}: prod is an object, ours is {actual.ValueKind}"); + return; + } + foreach (var prop in expected.EnumerateObject()) + { + if (isRoot && IgnoredEnvelopeKeys.Contains(prop.Name)) continue; + if (!actual.TryGetProperty(prop.Name, out var av)) + { + failures.Add($"{path}.{prop.Name}: MISSING — prod sends this key, we don't"); + continue; + } + CompareSubset(prop.Value, av, $"{path}.{prop.Name}", isRoot: false, failures); + } + break; + + case JsonValueKind.Array: + if (actual.ValueKind != JsonValueKind.Array) + { + failures.Add($"{path}: prod is an array, ours is {actual.ValueKind}"); + return; + } + if (expected.GetArrayLength() > 0) + { + if (actual.GetArrayLength() == 0) + { + failures.Add($"{path}: prod array is non-empty, ours is empty"); + return; + } + // Arrays here are uniform (decks, pos/idx lists) — element 0 defines the shape. + CompareSubset(expected[0], actual[0], $"{path}[0]", isRoot: false, failures); + } + break; + + case JsonValueKind.Null: + // Can't infer an expected type from a null; accept whatever we emit. + break; + + default: + var ec = Category(expected.ValueKind); + var ac = Category(actual.ValueKind); + if (ec != ac) + { + failures.Add( + $"{path}: type mismatch — prod is {ec} ({Trunc(expected)}), ours is {ac} ({Trunc(actual)})"); + } + break; + } + } + + private static string Category(JsonValueKind k) => k switch + { + JsonValueKind.String => "string", + JsonValueKind.Number => "number", + JsonValueKind.True or JsonValueKind.False => "bool", + JsonValueKind.Null => "null", + JsonValueKind.Object => "object", + JsonValueKind.Array => "array", + _ => k.ToString(), + }; + + private static string Trunc(JsonElement el) + { + var s = el.GetRawText(); + return s.Length > 40 ? s[..40] + "…" : s; + } + + private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri, long pubSeq, + Dictionary? body = null) => + new(uri, ViewerId: ViewerId, Uuid: "udid-test", Bid: null, Try: 0, + Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General + : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching + : EmitCategory.Battle, + PubSeq: pubSeq, PlaySeq: null, Body: new RawBody(body ?? new Dictionary())); + + private static string MakeKey() + { + var seq = 0; + return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16); + } +} + +/// +/// Representative server→client (receive) frames lifted verbatim from the prod TK2 captures. +/// One frame per server-authored URI, picked as the richest observed instance. The +/// selfDeck in Matched is trimmed to three cards (the array is uniform — three +/// entries are enough to lock the element shape). Numbers and string/number typing are preserved +/// exactly as captured, including the deliberate prod quirk that BattleStart.selfInfo.battlePoint +/// is a string while oppoInfo.battlePoint is a number. +/// +/// Provenance (line numbers in the capture files): +/// InitNetwork regular:1 | Matched regular:2 | BattleStart regular:3 +/// Deal regular:4 | Swap regular:7 | Ready regular:9 +/// TurnStart regular:14 | TurnEnd regular:18 | Judge regular:20 +/// BattleFinish regular:274 (result=102, a real loss capture) +/// +internal static class ProdCaptureFixture +{ + public const string Json = """ + { + "InitNetwork": { "uri": "InitNetwork", "resultCode": 1 }, + "Matched": { + "uri": "Matched", + "selfInfo": { + "country_code": "KOR", "userName": "combusty7", "sleeveId": "3000011", + "emblemId": "701441011", "degreeId": "300003", "fieldId": 43, + "isOfficial": 0, "oppoId": 847666884, "seed": 17548138 + }, + "oppoInfo": { + "country_code": "JPN", "userName": "AtagoSuki", "sleeveId": "704141010", + "emblemId": "400001100", "degreeId": "120027", "fieldId": 5, + "isOfficial": 0, "oppoId": 906243102, "seed": 17548138, "oppoDeckCount": 30 + }, + "selfDeck": [ + { "idx": 1, "cardId": 128111020 }, + { "idx": 2, "cardId": 128121010 }, + { "idx": 3, "cardId": 127134010 } + ], + "bid": "975695075012", "playSeq": 1, "resultCode": 1 + }, + "BattleStart": { + "uri": "BattleStart", + "turnState": 0, + "selfInfo": { + "rank": "10", "battlePoint": "6270", "classId": "1", "charaId": "1", + "cardMasterName": "card_master_node_10015" + }, + "oppoInfo": { + "rank": "25", "isMasterRank": "1", "battlePoint": 50000, "masterPoint": "2144", + "classId": "8", "charaId": "4608", "cardMasterName": "card_master_node_10015" + }, + "battleType": 11, "resultCode": 1, "playSeq": 2 + }, + "Deal": { + "uri": "Deal", + "self": [ { "pos": 0, "idx": 2 }, { "pos": 1, "idx": 16 }, { "pos": 2, "idx": 25 } ], + "oppo": [ { "pos": 0, "idx": 28 }, { "pos": 1, "idx": 20 }, { "pos": 2, "idx": 18 } ], + "playSeq": 3, "resultCode": 1 + }, + "Swap": { + "uri": "Swap", + "self": [ { "pos": 0, "idx": 2 }, { "pos": 1, "idx": 16 }, { "pos": 2, "idx": 25 } ], + "playSeq": 4, "resultCode": 1 + }, + "Ready": { + "uri": "Ready", + "self": [ { "pos": 0, "idx": 2 }, { "pos": 1, "idx": 16 }, { "pos": 2, "idx": 25 } ], + "oppo": [ { "pos": 0, "idx": 28 }, { "pos": 1, "idx": 24 }, { "pos": 2, "idx": 18 } ], + "idxChangeSeed": 771335280, "spin": 243, "playSeq": 5, "resultCode": 1 + }, + "TurnStart": { "uri": "TurnStart", "spin": 189, "resultCode": 1, "playSeq": 6 }, + "TurnEnd": { "uri": "TurnEnd", "turnState": 0, "resultCode": 1, "playSeq": 8 }, + "Judge": { "uri": "Judge", "spin": 55, "playSeq": 9, "resultCode": 1 }, + "BattleFinish": { "uri": "BattleFinish", "result": 102, "playSeq": 99, "resultCode": 1 } + } + """; +}