test(battle-node): lock server-authored frame shapes against prod captures
Add CaptureConformanceTests: drive one Scripted lifecycle, harvest all ten server-authored synchronize frames (InitNetwork/Matched/BattleStart/Deal/Swap/ Ready/TurnStart/TurnEnd/Judge/BattleFinish), re-serialize via MsgEnvelope.ToJson, and diff each against representative prod TK2 capture frames embedded as a fixture. Comparison is capture-subset-of-ours on body shape (recursive keys + value category), so missing/miscased/mistyped fields fail but extra envelope fields we emit don't; pure sequencing keys are excluded. Because PvP reuses the same ScriptedLifecycle builders for the handshake/mulligan frames, this transitively locks the PvP handshake shape -- a regression oracle that outlives the June-2026 server shutdown. Also replace the stale v1-only README with a pointer to the canonical docs/battle-node.md hub (outer repo). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,142 +1,21 @@
|
|||||||
# SVSim.BattleNode
|
# 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.
|
||||||
|
|
||||||
```
|
Relative path from here: [`../../../docs/battle-node.md`](../../../docs/battle-node.md).
|
||||||
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)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
```
|
Keep `docs/battle-node.md` updated in the same change whenever you alter node behavior.
|
||||||
┌────────┐ ┌────────────┐
|
|
||||||
│ 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://<node>/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<long>` 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 `BuildFor<Mode>Async(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<string, object?>` 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.
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wire-shape conformance of our server-authored synchronize frames against real prod TK2
|
||||||
|
/// captures (<c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c> +
|
||||||
|
/// <c>…_tk2_second.ndjson</c>, captured 2026-05-31 from a real client mid-PvP).
|
||||||
|
///
|
||||||
|
/// <para><b>What this guards:</b> 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 (<c>card_id</c> vs <c>cardID</c>), a missing field the client
|
||||||
|
/// reads without a guard, or a string where the client expects a number. The existing
|
||||||
|
/// <see cref="BattleNodeFlowTests"/> 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.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Direction of the check is capture ⊆ ours</b> — we must emit at least what prod emits
|
||||||
|
/// (missing/miscased/mistyped = fail), but we may emit extra envelope fields (we send
|
||||||
|
/// <c>viewerId/uuid/try/cat</c> on pushes; prod's receive frames omit them). Pure
|
||||||
|
/// envelope/sequencing keys (<c>viewerId, uuid, try, cat, bid, pubSeq, playSeq</c>) 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
|
||||||
|
/// <c>BattleFinish</c> frame is played immediately whether or not it carries a <c>playSeq</c>).
|
||||||
|
/// The check is on *body shape*.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>Coverage:</b> a single Scripted session emits all ten server-authored URIs
|
||||||
|
/// (<c>InitNetwork, Matched, BattleStart, Deal, Swap, Ready, TurnStart, TurnEnd, Judge,
|
||||||
|
/// BattleFinish</c>). PvP uses the same <see cref="SVSim.BattleNode.Lifecycle.ScriptedLifecycle"/>
|
||||||
|
/// builders for the handshake/mulligan frames, so this transitively covers the PvP handshake shape
|
||||||
|
/// too. Forwarded frames (<c>PlayActions / TurnEndActions / ChatStamp / TurnEndFinal</c>) relay the
|
||||||
|
/// client's own bytes verbatim, so their shape is the client's contract, not ours — out of scope
|
||||||
|
/// here.</para>
|
||||||
|
/// </summary>
|
||||||
|
[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<string> 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<IMatchingBridge>();
|
||||||
|
|
||||||
|
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<NetworkBattleUri, MsgEnvelope>();
|
||||||
|
|
||||||
|
async Task DriveAsync(NetworkBattleUri send, long pubSeq, int expectPushes,
|
||||||
|
Dictionary<string, object?>? 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<string, object?> { ["idxList"] = new List<object?>() });
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
foreach (var uriName in ExpectedUris)
|
||||||
|
{
|
||||||
|
var uri = Enum.Parse<NetworkBattleUri>(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",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recursively assert every key/element in <paramref name="expected"/> (the prod capture)
|
||||||
|
/// exists in <paramref name="actual"/> (our wire JSON) with a matching value category.
|
||||||
|
/// </summary>
|
||||||
|
private static void CompareSubset(JsonElement expected, JsonElement actual, string path,
|
||||||
|
bool isRoot, List<string> 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<string, object?>? 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<string, object?>()));
|
||||||
|
|
||||||
|
private static string MakeKey()
|
||||||
|
{
|
||||||
|
var seq = 0;
|
||||||
|
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Representative server→client (<c>receive</c>) frames lifted verbatim from the prod TK2 captures.
|
||||||
|
/// One frame per server-authored URI, picked as the richest observed instance. The
|
||||||
|
/// <c>selfDeck</c> in <c>Matched</c> 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 <c>BattleStart.selfInfo.battlePoint</c>
|
||||||
|
/// is a string while <c>oppoInfo.battlePoint</c> 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)
|
||||||
|
/// </summary>
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user