Files
SVSimServer/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs
gamer147 ba18790156 refactor(battle-node): rename ScriptedLifecycle->ServerBattleFrames, ScriptedProfiles->BattleFrameDefaults
Pure rename. These hold the shared server-authored frame builders used by every
battle mode's handshake/mulligan dispatch — the 'Scripted' name was a historical
accident that hid the PvP/Bot crossover. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:36:32 -04:00

383 lines
19 KiB
C#

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 two-client PvP session emits all ten server-authored URIs
/// (<c>InitNetwork, Matched, BattleStart, Deal, Swap, Ready, TurnStart, TurnEnd, Judge,
/// BattleFinish</c>). PvP authors the handshake/mulligan frames through the same shared
/// <see cref="SVSim.BattleNode.Lifecycle.ServerBattleFrames"/> builders, and the turn cycle
/// (<c>TurnStart/TurnEnd/Judge</c>) falls out of the real two-client handover. 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
{
// 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(60000)]
public async Task ServerAuthoredFrames_MatchProdCaptureShapes()
{
await using var factory = new SVSimTestFactory();
var bridge = factory.Services.GetRequiredService<IMatchingBridge>();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(45));
var ct = cts.Token;
// Two-client PvP drive. PvP authors the same handshake/mulligan frames the old Scripted
// path did (via the shared server-frame builders) PLUS the turn-cycle frames
// (TurnStart/TurnEnd/Judge) the scripted bot used to fake — so a two-client session
// harvests all ten server-authored URIs. The shape check is category-based, so PvP's
// spin:0 still matches prod's spin:189.
const long vidA = 906243102L;
const long vidB = 847666884L;
var pending = bridge.RegisterBattle(
new BattlePlayer(vidA, BattleNodeFlowTests.FixtureCtx()),
new BattlePlayer(vidB, BattleNodeFlowTests.FixtureCtx()),
SVSim.BattleNode.Sessions.BattleType.Pvp);
var key = MakeKey();
var (clientA, clientB) = await ConnectBothAsync(factory, pending.BattleId, vidA, vidB, key, ct);
await using var _a = clientA;
await using var _b = clientB;
await Task.WhenAll(clientA.ConsumeHandshakeAsync(ct), clientB.ConsumeHandshakeAsync(ct));
var harvested = new Dictionary<NetworkBattleUri, MsgEnvelope>();
void Harvest(MsgEnvelope env) => harvested[env.Uri] = env;
long seqA = 1, seqB = 1;
// A walks the handshake; Ready is withheld by the mulligan barrier until B also swaps.
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitNetwork, seqA++), key, ct);
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // InitNetwork ack
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.InitBattle, seqA++), key, ct);
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Matched
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Loaded, seqA++), key, ct);
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // BattleStart
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Deal
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Swap, seqA++,
new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Swap response
// B walks the handshake; B's Swap (the second) releases Ready to both sides.
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitNetwork, seqB++), key, ct);
await clientB.ReceiveSynchronizeAsync(ct); // ack
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.InitBattle, seqB++), key, ct);
await clientB.ReceiveSynchronizeAsync(ct); // Matched
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Loaded, seqB++), key, ct);
await clientB.ReceiveSynchronizeAsync(ct); // BattleStart
await clientB.ReceiveSynchronizeAsync(ct); // Deal
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Swap, seqB++,
new Dictionary<string, object?> { ["idxList"] = new List<object?>() }), key, ct);
await clientB.ReceiveSynchronizeAsync(ct); // B Swap response
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // Ready (released to A)
await clientB.ReceiveSynchronizeAsync(ct); // Ready to B
// Turn cycle: A ends turn -> B receives TurnEnd{turnState}. B sends Judge -> Judge{spin}
// reflects to B. B sends TurnStart -> A receives TurnStart{spin}.
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.TurnEnd, seqA++), key, ct);
Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // TurnEnd
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.Judge, seqB++), key, ct);
Harvest(await clientB.ReceiveSynchronizeAsync(ct)); // Judge
await clientB.SendMsgAsync(MakeEnvelope(vidB, NetworkBattleUri.TurnStart, seqB++), key, ct);
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // TurnStart
// BattleFinish: A retires.
await clientA.SendMsgAsync(MakeEnvelope(vidA, NetworkBattleUri.Retire, seqA++), key, ct);
Harvest(await clientA.ReceiveSynchronizeAsync(ct)); // 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 PvP 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 async Task<(RawSocketIoTestClient, RawSocketIoTestClient)> ConnectBothAsync(
SVSimTestFactory factory, string battleId, long vidA, long vidB, string key, CancellationToken ct)
{
var encA = NodeCrypto.EncryptForNode(vidA.ToString(), key);
var encB = NodeCrypto.EncryptForNode(vidB.ToString(), key);
var uriA = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encA)}&EIO=3&transport=websocket");
var uriB = new Uri($"ws://localhost/socket.io/?BattleId={battleId}&viewerId={Uri.EscapeDataString(encB)}&EIO=3&transport=websocket");
var wsClient = factory.Server.CreateWebSocketClient();
var connectATask = wsClient.ConnectAsync(uriA, ct);
await Task.Delay(50, ct);
var wsB = await wsClient.ConnectAsync(uriB, ct);
var wsA = await connectATask;
return (new RawSocketIoTestClient(wsA), new RawSocketIoTestClient(wsB));
}
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(long vid, NetworkBattleUri uri, long pubSeq,
Dictionary<string, object?>? body = null) =>
new(uri, ViewerId: vid, 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);
}
[Test]
public void SynthesizedKnownList_matches_prod_recv_PlayActions_entry_shape()
{
// Prod recv PlayActions knownList entry (battle-traffic_tk2_regular.ndjson:27).
const string prodEntry = """
{ "idx": 17, "cardId": 128821011, "to": 20, "cost": 2, "clan": 8, "tribe": "7,16", "spellboost": 0, "attachTarget": "" }
""";
// Build the same entry through our synthesizer.
var deckMap = new Dictionary<int, long> { [17] = 128821011L };
var orderList = new List<object?>
{
new Dictionary<string, object?>
{
["move"] = new Dictionary<string, object?>
{
["idx"] = new List<object?> { 17L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L,
}
}
};
var entry = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(deckMap, 17, orderList);
Assert.That(entry, Is.Not.Null);
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
var ourEntry = ourDoc.RootElement.GetProperty("knownList")[0];
using var prodDoc = JsonDocument.Parse(prodEntry);
// We are responsible for idx/cardId/to (+ spellboost/attachTarget). cost/clan/tribe are deferred.
foreach (var key in new[] { "idx", "cardId", "to" })
{
Assert.That(ourEntry.TryGetProperty(key, out var ours), Is.True, $"knownList entry missing '{key}'");
var prodVal = prodDoc.RootElement.GetProperty(key);
Assert.That(ours.ValueKind, Is.EqualTo(prodVal.ValueKind), $"'{key}' type category mismatch");
}
Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(128821011L));
}
}
/// <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 }
}
""";
}