Compare commits

..

13 Commits

Author SHA1 Message Date
gamer147
fb1e91cdf1 Diagnostic logging 2026-06-02 22:10:18 -04:00
gamer147
c551d7b05e refactor(battle-node): drop dead BattleResult.{Lose,Win,Consistency} members
No dispatch arm has emitted these since the Retire/Kill rewrite to RetireWin=105
/ RetireLose=106. Remove them and the docstring paragraph that explained them.

Test fallout: delete BattleFinishBody_LoseAndConsistency_SerializeAsZeroAndTwo
(its only purpose was locking the dead wire values), and re-point
BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues at the live
LifeWin=101 so it still guards the JsonNumberEnumConverter numeric-wire behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 21:17:30 -04:00
gamer147
d76b96b339 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>
2026-06-02 20:03:56 -04:00
gamer147
a198174ede fix(battle-node): involuntary-drop survivor gets DisconnectWin, not Win=NoContest
Code-review follow-up to the dispatch unification (0a8a84b).

1. The RunAsync drop cascade synthesized BattleFinish(Win=1), which the client
   renders as RESULT_CODE.NoContest ("battle ended in no contest") instead of a
   win. Add DisconnectWin=201 (already in the client enum, routes to WIN UI) and
   ship it for involuntary opponent drops. Update PvpMidGameDisconnect_FullCascade.

2. Remove BuildBattleFinishNoContest() — dead since the Retire/Kill arm moved to
   RetireWin/RetireLose.

3. Correct the BattleResult docstring: Lose/Win/Consistency are no longer emitted
   by any dispatch arm; they survive only as serialization-test constants.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:26:16 -04:00
gamer147
0a8a84b2cc refactor(battle-node): unify TurnEndFinal / Retire-Kill / gameplay-forwarder dispatch across types
Three dispatch arms had Type-based branching that was either wrong or
unnecessary. Unified per the audit doc's recommended order, grounded in
verified facts about each participant's PushAsync.

(1) TurnEndFinal — was branched: PvP broadcast TurnEnd+Judge (wrong on a
game-end signal); Scripted pushed BattleFinish(LifeWin). Unified:
  - forward the envelope to other (matches prod TK2 capture
    battle-traffic_tk2_regular.ndjson:273 — loser receives TurnEndFinal
    from server before BattleFinish)
  - push BattleFinish(LifeWin) to from (winner)
  - push BattleFinish(LifeLose) to other (loser)
  - Phase → Terminal

  Requires ScriptedBotParticipant.PushAsync to no longer fire its 3-frame
  burst on TurnEndFinal (previously it reacted to both TurnEnd and
  TurnEndFinal). The dispatch arm now owns TurnEndFinal's response; the
  bot reacting too would race with the BattleFinish push. Bot still
  fires on regular TurnEnd as before.

(2) Retire / Kill — was branched: PvP pushed Lose=0 (NotFinish) /
Win=1 (NoContest); Scripted pushed BuildBattleFinishNoContest() (Win=1).
Both shipped wrong RESULT_CODE values; the audit doc's outstanding item
documented this. Unified:
  - push BattleFinish(RetireLose=106) to from (the retirer)
  - push BattleFinish(RetireWin=105) to other (the survivor)
  - Phase → Terminal

  Added RetireWin=105 / RetireLose=106 to BattleResult enum with the
  same player-perspective convention.

(3) PvP gameplay forwarder (TurnStart / PlayActions / Echo /
TurnEndActions / JudgeResult) — had a redundant `Type == BattleType.Pvp`
guard. Verified that BothAfterReady() is naturally only true when both
participants are RealParticipant (ScriptedBot / NoOpBot don't implement
IHasHandshakePhase per RealParticipant.cs:20-23 / Participants/*.cs grep).
Dropped the redundant guard.

Bot type still has its dedicated InitBattle/Loaded/TurnEnd arms above
the unified ones, so Bot-specific behavior is unchanged.

Tests: 177 battle-node tests passing.
- Updated 9 tests to match the unified dispatch (paired BattleFinish
  pushes, correct RESULT_CODE values, forwarded TurnEndFinal envelope).
- ScriptedBotParticipantTests.PushAsync_TurnEndFinal_* rewritten to
  assert the bot does NOT fire on TurnEndFinal (was asserting it did).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:37:24 -04:00
gamer147
1685b509c3 fix(battle-node): TurnEndFinal pushes LifeWin=101 (player perspective), not LifeLose=102
End-to-end trace of FinishBattleEffect proved my prior direction was
backwards. The path is:

  RESULT_CODE → JudgeResultReceive switch (NetworkBattleManagerBase:1439-1459)
              → SettingResultUI_SpecialResultTypeText
              → _finishEffectType = battleResult
  → eventually FinishBattleEffect(:1267-1316):
      bool isPlayer = false;
      switch (_finishEffectType) {
        case WIN:  isPlayer = true;  break;
        case LOSE: isPlayer = false; break;
      }
      InitiateGameEndSequence(!isPlayer);  // NEGATED
  → BattleManagerBase.InitiateGameEndSequence(hasWon):
      hasWon=true → WIN screen; hasWon=false → LOSE screen.

So LifeWin=101 (player perspective: "I won by life") → _finishEffectType=LOSE
→ isPlayer=false → hasWon=true → WIN UI. And LifeLose=102 ("I lost") → LOSE UI.

My prior misread treated the inner switch's BATTLE_RESULT_TYPE param as
the final UI render — but that param only feeds the secondary "by retire
/ by disconnect" text, not the primary WIN/LOSE. The real flip happens at
FinishBattleEffect:1315's !isPlayer negation.

User's live repro (bot HP to 0 → LOSS screen) confirmed the inversion.

The prior prod TK2 capture interpretation was also corrected: line 274
`result:102` was a LOSS capture (player lost to the opponent's attack on
line 271), not a win as I claimed earlier.

Changes:
- BattleResult.cs: docstring rewritten with the full FinishBattleEffect
  trace. Members reordered (LifeWin first since it's used by Scripted).
- BattleSession.cs:267: Scripted TurnEndFinal arm pushes LifeWin instead
  of LifeLose.
- Test updated to assert LifeWin=101 + describe the inversion lesson so
  the next reader sees the prior bug context.

177 battle-node tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 18:11:24 -04:00
gamer147
ee23985055 fix(battle-node): push BattleFinish on Scripted TurnEndFinal so the client doesn't park on the disconnect-checker
Before: when the player declared their final winning turn (TurnEndFinal),
Scripted mode forwarded it to the bot — which fired a useless 3-frame
TurnStart/TurnEnd/Judge burst as if the game were continuing. No
BattleFinish was ever pushed, so the client's
BattleFinishToOpponentDisConnectChecker (NetworkBattleManagerBase.cs:1640
+ BattleFinishToOpponentDisConnectChecker.cs) parked the player on a
"waiting for opponent" dialog for 128 seconds, eventually falling through
to a synthetic OnDisConnectWin. The user could see "opponent defeated"
animations but couldn't proceed to the post-battle screen.

After: Scripted TurnEndFinal pushes BattleFinish with result=LifeLose=102
to the player (matches the RESULT_CODE the client expects per
NetworkBattleReceiver.cs:963-986; client maps LifeLose → "opponent's life
ran out, PLAYER WIN" UI per NetworkBattleManagerBase.cs:1450-1459). Phase
transitions to Terminal so RunAsync's PvP-disconnect cascade doesn't
synthesize a second BattleFinish on top. No bot burst — the game is
over.

Wire reference: prod TK2 capture battle-traffic_tk2_regular.ndjson:273-274
shows server pushing TurnEndFinal followed immediately by BattleFinish
result:102.

BattleResult enum gets the LifeWin=101 / LifeLose=102 values and a
corrected docstring. The pre-existing Lose=0 / Win=1 / Consistency=2
values stay (Retire/Kill flow ships them today and works as "no contest"
end-of-battle), but their docstring no longer claims they're the WS
shape — they were always the HTTP /finish shape, mislabeled.

TurnEnd (regular, not final) keeps the existing forward-to-bot behavior
in Scripted mode — that's a normal turn boundary, not game end.

PvP TurnEndFinal still broadcasts the same TurnEnd+Judge as regular
TurnEnd; the actual game-end BattleFinish push in PvP rides the loser's
Retire/Kill or the disconnect cascade in RunAsync.

177 battle-node tests passing (was 176; +1 covering the new dispatch arm).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:48:20 -04:00
gamer147
c7e61c6f8d fix(battle-node): hand events are unencrypted JSON arrays, not encrypted dicts
The prior 'hand'-ack fix worked in test but failed in prod because both
the handler and the test used the wrong wire shape. Re-tracing the
client emit path:

  RealTimeNetworkAgent.cs:783-786 (msg path):
    return MessagePackSerializer.Serialize(
        CryptAES.encryptForNode(JsonMapper.ToJson(info)));  // ← encrypted

  RealTimeNetworkAgent.cs:815-817 (hand path):
    return MessagePackSerializer.Serialize(
        JsonMapper.ToJson(info));                           // ← NOT encrypted

And EmitFrontStockData:717-723 picks "hand" as the SIO event name only
when frontData["StockHandData"] exists; in that branch it passes the
StockHandData list (NOT the dict) to CreatePackEmitHandData. So the
wire body is:

  msgpack_string(JsonMapper.ToJson(List<object>))

i.e. a JSON array, unencrypted. EmitMsgUriPack:1456-1458 puts pubSeq at
index 3 of that array (after uri_int / viewerId / udid). The dict's
top-level pubSeq stays client-local for stockEmitMessageMgr.GetSelectData.

Handler now:
- Skips NodeCrypto.DecryptForNode (was throwing FormatException on the
  unencrypted bytes — caught and swallowed silently by the existing
  outer try/catch, so the bug presented as 'no warning, no ack')
- Parses RootElement.ValueKind:
  - Array → arr[3] is the pubSeq
  - Object → top-level "pubSeq" (defensive; not used by prod today)
- Falls back to ack arg=0 if neither extraction works (the client's
  GetSelectData lookup misses but its OnAck path still fires — same as a
  normal cache-miss — so the queue still drains)

Diagnostic [hand-rx] log added (gated by DiagnosticLogging) so we can
see the actual body content per-frame during verification.

Test was also wrong (encrypted dict shape); rewritten to use the real
wire shape (unencrypted JSON array). +1 net new test covering the
dict-shape defensive path.

176 battle-node tests passing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:14:13 -04:00
gamer147
8f270a87f0 refactor(battle-node): gate WS diagnostic logging behind config flag
The temporary [sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit] logs added during the hand-ack
investigation are useful enough to keep around (PvP testing, future WS
debugging) but too chatty to leave on by default. Promote them from
"strip before merge" to a permanent opt-in.

New BattleNodeOptions.DiagnosticLogging (bool, default false). Wired
through BattleNodeWebSocketHandler to RealParticipant via a new optional
ctor parameter (default false — existing test sites pick up the silent
default with no changes). Every Information/Warning log added during the
investigation is now if-gated; non-diagnostic logs (the decode-failure
warnings, the dispatch-drop debug) stay as-is.

Toggle via appsettings*.json:
  "BattleNode": { "DiagnosticLogging": true }

Or live via the singleton:
  factory.Services.GetRequiredService<BattleNodeOptions>().DiagnosticLogging = true

175 battle-node tests still passing — existing tests use the constructor
default and emit nothing, so no test changes were required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:49:17 -04:00
gamer147
9fc1d055d8 fix(battle-node): ack 'hand' SIO events to unblock client emit queue
Scripted-bot softlock root cause: client-stocked SELECT_SKILL_URI /
SLIDE_OBJECT_URI hand emits (e.g. target selection on unit play / leader
attack) arrive as SIO BinaryEvent("hand", ...) with an ack-id. Our
DispatchSocketIo only had cases for "msg" and "alive" — "hand" fell to
the default Debug-drop with no SIO ack going back. Client's
stockEmitMessageMgr (RealTimeNetworkAgent.cs:1463) blocks subsequent
emits until the previous one is acked, so all follow-up PlayActions /
TurnEndActions / TurnEnd frames were stocked but never transmitted. The
loader hooks at EmitMsg (intent) not the socket layer, which is why
battle-traffic.ndjson shows the frames as sent while the server never
received them. ~10s later the client gives up and aborts the WS.

Wire-shape proof from data_dumps/captures/logs/websocket_output.txt:
  line 619: [sio-in] uri=TurnStart pubSeq=17 ackId=16 ... (T3 start)
  line 689: [ws-rx-text] preview=451-26["hand", {...}] ← unhandled
  line 691: [ws-rx-bin]  binLen=58 pendingFrame=hand
  (no further [sio-in] entries — server received nothing else)
  line 709: [ws-recv-exit] reason=OperationCanceled wsState=Aborted

New HandleHandEventAsync (RealParticipant.cs):
- Fire-and-forget hand frames (no ack-id; TOUCH_URI / SELECT_OBJECT_URI /
  TURN_END_READY_URI) are silently swallowed — no queue-blocking risk
- Stocked hand frames decode the binary attachment via the same
  msgpack-string + NodeCrypto.Decrypt pipeline as HandleMsgEventAsync,
  parse the JSON, extract top-level "pubSeq", and SendSioAckAsync with
  that pubSeq as the ack arg (matches what stockEmitMessageMgr.GetSelectData
  expects to look up)
- Body shape is {"StockHandData":[uri_int, viewerId, udid, ...params,
  pubSeq], "try":0, "pubSeq":N} — NOT a MsgEnvelope (no top-level "uri"),
  so we can't reuse HandleMsgEventAsync as-is
- Missing-pubSeq fallback acks with arg=0 (rare path, logged at Warning)
  so we never softlock from a malformed body

WireConstants gets the HandEvent = "hand" constant for the dispatch case.

In scripted/Bot mode the ack-only handler is correct (no opponent to
forward touches to). PvP-side forwarding semantics are unverified — see
docs/audits/battle-node-sio-events-2026-06-02.md (outer repo) for the
full event inventory and remaining gaps.

Tests:
- RealParticipantHandEventTests covers the three paths: stocked-with-ack,
  fire-and-forget (no ack expected), missing-pubSeq fallback (arg=0). Each
  drives a real hand frame through RunAsync via TestWebSocket and asserts
  the SIO ack frame shape (43<ackId>[<arg>]) in outbound sends.
- 175 battle-node tests passing (was 172; +3 new). Full suite green.

Diagnostic logs ([sio-in] / [sio-out] / [ws-rx-text] / [ws-rx-bin] /
[ws-recv-exit] / [ws-loop-exit]) are left in place for one verification
cycle. After a live re-run confirms the fix, they should be stripped per
the audit doc's recommended-order step 2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 16:41:40 -04:00
gamer147
672a89ed46 refactor(matching): IMatchingResolver shared by every do_matching family
SoloDefaultsToScripted was only consulted by ArenaTwoPickBattleController;
RankBattleController did its own inline pair-up + state-code mapping and
ignored the flag entirely. Result: turning on the flag globally only
short-circuited TK2 polls, while rank-battle polls still parked for the
PvpFirstThenAiFallback threshold (15s) before resolving — surfaced today
when the user set the flag and saw rank-battle still queue, then bot-
battle via the client-side AI (not the server-side Scripted lifecycle we
need to test WS traffic against).

New IMatchingResolver owns the cross-cutting decisions:
- honor scriptedOptIn (per-request) OR options.SoloDefaultsToScripted
  (process-wide) — bypass pair-up, register Scripted, return 3004
- otherwise call IMatchingPairUpService.TryPairAsync and translate the
  PairUpResult to the 3002/3004/3007/3011 vocabulary

Family controllers shed the duplicated logic:
- ArenaTwoPickBattleController: ~50 LOC → ~25; preserves ?scripted=1
  query opt-in (parsed permissively for "1"/"true") and the
  ArenaTwoPickException catch
- RankBattleController: ~30 LOC → ~12; preserves the 3001 mapping for
  InvalidOperationException (no deck for format) and card_master_id
  emission

DoMatchingContractTests is the durable enforcement: parametrized over
TK2 + rotation + unlimited rank, asserts SoloDefaultsToScripted=true
makes every family's first poll skip 3002 and return SUCCEEDED with a
battle_id + node_server_url. Adding a fourth family that forgets to
route through IMatchingResolver fails this test — that's the point.

MatchingResolverTests covers the six resolver paths in isolation with
mocks; per-test Harness locals (not fixture-level fields) because the
assembly is [Parallelizable(ParallelScope.All)] and shared mocks race.

957 tests passing (was 948; +9: 6 resolver + 3 contract parametrizations).
No regressions in the existing TK2 / rank-battle controller suites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 15:18:48 -04:00
gamer147
9f11896f7b feat(battle-node): polite Socket.IO close on waiting-room timeout
The PvP waiting-room timeout path in BattleNodeWebSocketHandler used to
return immediately after RemovePending, leaving the parked first arriver
to learn about the disconnect via TCP teardown after Kestrel finished
draining the request. BestHTTP / socket.io-client log that as an abrupt
drop rather than a controlled disconnect.

New TryPoliteCloseAsync helper emits an EIO "1" (Close) text frame, then
runs the WebSocket close handshake with NormalClosure. Wrapped in
try/catch + Debug log — teardown races between the server-side close and
client disconnect are routine and not actionable. Uses a fresh 5s CTS so
ctx.RequestAborted being canceled doesn't skip the close.

Wired into both bail-out paths post-AcceptWebSocketAsync that previously
just returned:
- PvP waiting-room timeout / Park-Park race (the main case, per PLAN.md
  L104 (c))
- Unknown BattleType default case (same shape, log message already said
  "closing WS" but didn't actually close — opportunistic fix)

PvpWaitingRoomTimeout integration test tightened: now asserts the polite
"1" text frame arrives before the close handshake, not just that the WS
eventually closes by any means.

172 battle-node tests passing (was 172 before the assertion tightening;
the existing timeout test stayed in.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 14:05:25 -04:00
gamer147
a6b9a942ab chore(battle-node): delete dead ScriptedProfiles.Opponent{Matched,BattleStart}Profile fields
Phase 2 absorbed the scripted opponent cosmetics + class/chara fixture
into ScriptedBotParticipant.Context; the two profile fields have been
unreferenced since (kept one phase as documentation tie-back, per PLAN.md
L104 (d)). The Context comments now describe the values directly with
frame[N] provenance instead of pointing at the deleted fields. Also
removes the now-unused SVSim.BattleNode.Protocol.Bodies import from
ScriptedProfiles.cs.

948 tests passing (unchanged).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 13:58:32 -04:00
23 changed files with 1360 additions and 398 deletions

View File

@@ -26,4 +26,18 @@ public sealed class BattleNodeOptions
/// is the only way to get PvP behavior.</para>
/// </summary>
public bool SoloDefaultsToScripted { get; set; } = false;
/// <summary>
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
/// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound
/// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, noStock);
/// <c>[ws-rx-text]</c> / <c>[ws-rx-bin]</c> on every WS frame received at the transport
/// layer; <c>[ws-recv-exit]</c> / <c>[ws-loop-exit]</c> on read-loop termination
/// (with WebSocket state + exception type when applicable). Default false — keeps
/// production logs clean. Flip on per session for live WS debugging, PvP investigation,
/// or to reproduce the kind of softlock chased in
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
/// </summary>
public bool DiagnosticLogging { get; set; } = false;
}

View File

@@ -1,3 +1,5 @@
using System.Net.WebSockets;
using System.Text;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Bridge;
@@ -122,7 +124,7 @@ public sealed class BattleNodeWebSocketHandler
{
_store.RemovePending(battleId);
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>());
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var scriptedBot = new ScriptedBotParticipant();
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
_loggerFactory.CreateLogger<BattleSession>());
@@ -135,7 +137,7 @@ public sealed class BattleNodeWebSocketHandler
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
var self = new RealParticipant(ws, viewerId, selfCtx,
_loggerFactory.CreateLogger<RealParticipant>());
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var firstArriver = _waitingRoom.Pair(battleId, self);
@@ -172,6 +174,7 @@ public sealed class BattleNodeWebSocketHandler
"PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.",
battleId);
_store.RemovePending(battleId);
await TryPoliteCloseAsync(ws, "waiting-room timeout", battleId);
return;
}
// Retry succeeded — we're the de-facto second arriver now. Own the session.
@@ -199,7 +202,7 @@ public sealed class BattleNodeWebSocketHandler
// earlier isP1/isP2 check has already rejected viewer mismatches.
_store.RemovePending(battleId);
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
_loggerFactory.CreateLogger<RealParticipant>());
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
var noopBot = new NoOpBotParticipant();
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
_loggerFactory.CreateLogger<BattleSession>());
@@ -209,6 +212,7 @@ public sealed class BattleNodeWebSocketHandler
default:
_log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId);
await TryPoliteCloseAsync(ws, $"unknown BattleType={pending.Type}", battleId);
return;
}
}
@@ -219,4 +223,37 @@ public sealed class BattleNodeWebSocketHandler
if (!string.IsNullOrEmpty(header)) return header;
return ctx.Request.Query[name].ToString();
}
/// <summary>
/// Emit an EIO <c>1</c> (Close) text frame, then run the WebSocket close handshake with
/// <see cref="WebSocketCloseStatus.NormalClosure"/>. Without the EIO frame, BestHTTP /
/// socket.io-client log the disconnect as an abrupt drop rather than a controlled
/// disconnect; without the close handshake, the client only sees the TCP teardown after
/// Kestrel finishes draining. Best-effort: any exception (already-torn-down socket,
/// canceled token) is swallowed at Debug level since teardown races are routine.
/// </summary>
private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId)
{
// Use a fresh, short timeout — ctx.RequestAborted may already be canceled by the
// path that decided to bail out, which would skip the close immediately.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
if (ws.State == WebSocketState.Open)
{
var bytes = Encoding.UTF8.GetBytes(((int)EngineIoPacketType.Close).ToString());
await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cts.Token);
}
if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, cts.Token);
}
}
catch (Exception ex)
{
_log.LogDebug(ex,
"polite close failed on BattleId={Bid} (reason={Reason}); socket likely already torn down.",
battleId, reason);
}
}
}

View File

@@ -1,5 +1,3 @@
using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
@@ -15,32 +13,11 @@ internal static class ScriptedProfiles
// From frame[2] (Matched).
public const long BattleSeed = 17_548_138L;
public static readonly MatchedOppoInfo OpponentMatchedProfile = new(
CountryCode: "JPN",
UserName: "Opponent",
SleeveId: "704141010",
EmblemId: "400001100",
DegreeId: "120027",
FieldId: 5,
IsOfficial: 0,
OppoId: 0,
Seed: BattleSeed,
OppoDeckCount: 30);
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
// from real per-viewer state needs a TK2 rank/battle-point tracker.
public const string PlayerRank = "10";
public const string PlayerBattlePoint = "6270";
public static readonly BattleStartOppoInfo OpponentBattleStartProfile = new(
Rank: "1",
IsMasterRank: "0",
BattlePoint: 0,
MasterPoint: "0",
ClassId: "8",
CharaId: "8",
CardMasterName: "card_master_node_10015");
// From frame[8] (Ready). Provenance is "what prod sent"; the client
// doesn't validate, but echoing matches the capture protects against
// a regression on a future tightening.

View File

@@ -1,19 +1,63 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wire value of <c>result</c> on a BattleFinish frame. The client's
/// <c>BattleFinishResponsProcessing</c> switch maps these as:
/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch).
/// </summary>
/// <remarks>
/// This is NOT the same as the client's in-memory <c>BATTLE_RESULT_TYPE</c> enum
/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0.
/// Wire value of <c>result</c> on a WS <c>BattleFinish</c> frame.
/// <para>
/// Maps to the client's <c>NetworkBattleReceiver.RESULT_CODE</c> enum at
/// <c>NetworkBattleReceiver.cs:963-986</c>. Names are <b>from the player's perspective</b>:
/// <c>LifeWin</c> = "I won by life", <c>LifeLose</c> = "I lost by life". Verified
/// end-to-end via the path
/// <c>RESULT_CODE</c> → <c>JudgeResultReceive</c> switch → <c>_finishEffectType</c> →
/// <c>FinishBattleEffect</c> → <c>InitiateGameEndSequence(hasWon)</c>:
/// </para>
/// <list type="bullet">
/// <item><c>LifeWin = 101</c> → <c>_finishEffectType = LOSE</c> →
/// <c>InitiateGameEndSequence(hasWon: true)</c> → PLAYER WIN UI</item>
/// <item><c>LifeLose = 102</c> → <c>_finishEffectType = WIN</c> →
/// <c>InitiateGameEndSequence(hasWon: false)</c> → PLAYER LOSE UI</item>
/// </list>
/// <para>
/// The <c>SettingResultUI_SpecialResultTypeText</c> switch passes the OPPONENT's
/// outcome to set the secondary "by retire/by disconnect/etc." text — that's why
/// the inner switch direction looks inverted (LifeWin → LOSE param, LifeLose → WIN
/// param). The actual WIN/LOSE rendering happens in <c>FinishBattleEffect</c> via
/// the <c>!isPlayer</c> flip at line 1315.
/// </para>
/// <para>
/// Prior docstrings on this enum had the direction backwards (claimed LifeLose
/// → WIN UI from a misread of the inner switch); see
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c> Addendum for the live
/// reproduction that exposed the inversion.
/// </para>
/// <para>
/// Always serialize as the int value, not the name; see the
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
/// </remarks>
/// </para>
/// </summary>
public enum BattleResult
{
Lose = 0,
Win = 1,
Consistency = 2,
/// <summary>Player won by reducing opponent's life to 0. Pushed to the winner
/// on <c>TurnEndFinal</c>. Routes through the client switch to
/// <c>InitiateGameEndSequence(hasWon: true)</c>.</summary>
LifeWin = 101,
/// <summary>Player lost by their own life dropping to 0. Pushed to the loser on
/// the opponent's <c>TurnEndFinal</c>. Prod TK2 capture at
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson:274</c> is a loss
/// shown to the player from a real opponent's lethal.</summary>
LifeLose = 102,
/// <summary>Player won because opponent retired. Pushed to the survivor on
/// <c>Retire</c>/<c>Kill</c>. Same player-perspective convention as the Life codes.</summary>
RetireWin = 105,
/// <summary>Player lost by retiring. Pushed to the retirer on <c>Retire</c>/<c>Kill</c>.</summary>
RetireLose = 106,
/// <summary>Survivor wins because the opponent's socket dropped without a graceful
/// <c>Retire</c>. Pushed to the survivor by the <see cref="Sessions.BattleSession"/>
/// RunAsync drop cascade. Client <c>RESULT_CODE.DisconnectWin</c> renders the
/// "opponent disconnected" result text → player WIN UI. Same player-perspective
/// convention as the Life/Retire codes.</summary>
DisconnectWin = 201,
}

View File

@@ -16,6 +16,15 @@ internal static class WireConstants
/// <summary>SIO event name for Gungnir keepalive frames (both directions).</summary>
public const string AliveEvent = "alive";
/// <summary>
/// SIO event name for client-emitted hand frames (touches + skill/object selection).
/// Stocked variants (<c>SELECT_SKILL_URI</c>, <c>SLIDE_OBJECT_URI</c>) carry an ack-id;
/// fire-and-forget variants (<c>TOUCH_URI</c>, <c>SELECT_OBJECT_URI</c>,
/// <c>TURN_END_READY_URI</c>) do not. The body wire shape differs from <c>msg</c>
/// frames — see <c>HandleHandEventAsync</c>.
/// </summary>
public const string HandEvent = "hand";
/// <summary>
/// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
/// real per-request UUID; the client doesn't validate it.

View File

@@ -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://<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.
Keep `docs/battle-node.md` updated in the same change whenever you alter node behavior.

View File

@@ -58,11 +58,13 @@ public sealed class BattleSession
if (Phase != BattleSessionPhase.Terminal)
{
// Involuntary drop (no graceful Retire): synthesize BattleFinish(Win) to survivor.
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
// WIN UI; the legacy Win=1 used here previously rendered "no contest".
try
{
await survivor.PushAsync(
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
.ConfigureAwait(false);
}
catch (Exception ex)
@@ -220,12 +222,11 @@ public sealed class BattleSession
break;
}
// Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame
// burst; PvP broadcasts; Bot stays silent.
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
if (Type == BattleType.Pvp && BothAfterReady())
{
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
// ControlTurnStartPlayer advances the active-player state machine.
var turnEndBroadcast = BuildTurnEndBroadcast();
var judgeBroadcast = BuildJudgeBroadcast();
result.Add((from, turnEndBroadcast, false));
@@ -235,24 +236,34 @@ public sealed class BattleSession
}
else if (Type == BattleType.Scripted)
{
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
result.Add((other, env, false));
}
// For Bot type, no-op (NoOpBot swallows; client handles its own turn end).
// Bot type: no-op (NoOpBot swallows; client handles its own turn end).
break;
// TurnEndFinal: client signals the player's FINAL turn is over (game-end
// condition met, usually killed opponent's leader). Unified across types:
// forward the envelope to other (matches prod TK2 capture
// battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
// from server before BattleFinish), then push BattleFinish per-side with
// player-perspective codes (LifeWin to winner, LifeLose to loser).
// ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
// this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
// so the RunAsync cascade doesn't synthesize a follow-up BattleFinish.
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
result.Add((other, env, false));
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
Phase = BattleSessionPhase.Terminal;
break;
// Retire / Kill: sender concedes (Retire) or the client requested an immediate
// terminate (Kill). Unified across types: push BattleFinish per-side with the
// proper retire codes. Bots swallow their push (no real-opponent state).
case NetworkBattleUri.Retire:
case NetworkBattleUri.Kill:
if (Type == BattleType.Pvp)
{
result.Add((from, BuildBattleFinish(BattleResult.Lose), true));
result.Add((other, BuildBattleFinish(BattleResult.Win), true));
}
else
{
// Scripted (and future Bot) — sender wins by default (no real opponent).
result.Add((from, BuildBattleFinishNoContest(), true));
}
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
Phase = BattleSessionPhase.Terminal;
break;
@@ -274,14 +285,17 @@ public sealed class BattleSession
result.Add((other, env, false));
break;
// --- PvP gameplay forwarding (post-AfterReady).
// Order matters: this MUST come after the FakeOpponentViewerId arms so
// Scripted bot emissions don't fall into the PvP forwarder.
case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady():
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
// BothAfterReady() is only true when both participants are RealParticipants
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
// always null), so this arm naturally fires for PvP only. Order matters:
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
// emissions don't fall into this forwarder.
case NetworkBattleUri.TurnStart when BothAfterReady():
case NetworkBattleUri.PlayActions when BothAfterReady():
case NetworkBattleUri.Echo when BothAfterReady():
case NetworkBattleUri.TurnEndActions when BothAfterReady():
case NetworkBattleUri.JudgeResult when BothAfterReady():
result.Add((other, env, false));
break;
@@ -322,17 +336,6 @@ public sealed class BattleSession
PlaySeq: null,
Body: new ResultCodeOnlyBody());
private MsgEnvelope BuildBattleFinishNoContest() => new(
NetworkBattleUri.BattleFinish,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid,
Bid: null,
Try: 0,
Cat: EmitCategory.Battle,
PubSeq: null,
PlaySeq: null,
Body: new BattleFinishBody(Result: BattleResult.Win));
private MsgEnvelope BuildTurnEndBroadcast() => new(
NetworkBattleUri.TurnEnd,
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,

View File

@@ -33,6 +33,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{
private readonly WebSocket _ws;
private readonly ILogger<RealParticipant> _log;
private readonly bool _diagnosticLogging;
private CancellationToken _sessionCt;
public long ViewerId { get; }
@@ -85,10 +86,11 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
}
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
ILogger<RealParticipant> log)
ILogger<RealParticipant> log, bool diagnosticLogging = false)
{
_ws = ws;
_log = log;
_diagnosticLogging = diagnosticLogging;
ViewerId = viewerId;
Context = context;
}
@@ -101,47 +103,78 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
var buffer = new byte[8192];
var pendingAttachments = new List<byte[]>();
SocketIoFrame? pendingFrame = null;
string exitReason = "loop-condition-false";
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
try
{
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
if (msg is null) break;
if (msg.Value.IsText)
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
{
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
if (eio.Type == EngineIoPacketType.Ping)
{
await SendTextAsync("3", cancellation);
continue;
}
if (eio.Type != EngineIoPacketType.Message) continue;
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
if (msg is null) { exitReason = "read-returned-null"; break; }
var sio = SocketIoFrame.Parse(eio.Payload);
if (sio.AttachmentCount > 0)
if (msg.Value.IsText)
{
pendingFrame = sio;
pendingAttachments.Clear();
continue;
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
if (_diagnosticLogging)
{
_log.LogInformation(
"[ws-rx-text] viewer={Vid} eioType={Eio} len={Len} preview={Preview}",
ViewerId, eio.Type, text.Length,
text.Length > 60 ? text.Substring(0, 60) + "..." : text);
}
if (eio.Type == EngineIoPacketType.Ping)
{
await SendTextAsync("3", cancellation);
continue;
}
if (eio.Type != EngineIoPacketType.Message) continue;
var sio = SocketIoFrame.Parse(eio.Payload);
if (sio.AttachmentCount > 0)
{
pendingFrame = sio;
pendingAttachments.Clear();
continue;
}
await DispatchSocketIo(sio);
}
else
{
var bin = msg.Value.Bytes;
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
{
bin = bin.AsSpan(1).ToArray();
}
pendingAttachments.Add(bin);
if (_diagnosticLogging)
{
_log.LogInformation(
"[ws-rx-bin] viewer={Vid} binLen={Len} pendingFrame={Pending} attachCount={AttachCount}",
ViewerId, bin.Length, pendingFrame?.EventName ?? "(null)", pendingAttachments.Count);
}
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
{
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
pendingFrame = null;
await DispatchSocketIo(assembled);
}
}
await DispatchSocketIo(sio);
}
else
}
catch (Exception ex)
{
exitReason = $"throw:{ex.GetType().Name}:{ex.Message}";
throw;
}
finally
{
if (_diagnosticLogging)
{
var bin = msg.Value.Bytes;
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
{
bin = bin.AsSpan(1).ToArray();
}
pendingAttachments.Add(bin);
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
{
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
pendingFrame = null;
await DispatchSocketIo(assembled);
}
_log.LogWarning(
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
}
}
}
@@ -149,6 +182,12 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
if (_diagnosticLogging)
{
_log.LogInformation(
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}",
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock);
}
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
}
@@ -180,6 +219,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
await HandleAliveEventAsync(frame);
return;
case WireConstants.HandEvent when frame.BinaryAttachments.Count == 1:
await HandleHandEventAsync(frame);
return;
}
}
_log.LogDebug("RealParticipant viewer={Vid}: dropping SIO event={Event}", ViewerId, frame.EventName);
@@ -198,14 +240,25 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
}
bool shouldDispatch = true;
bool ackSent = false;
long? ackArg = null;
if (env.PubSeq.HasValue)
{
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
if (frame.AckId.HasValue)
{
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
ackSent = true;
ackArg = env.PubSeq.Value;
}
}
if (_diagnosticLogging)
{
_log.LogInformation(
"[sio-in] viewer={Vid} uri={Uri} pubSeq={Pseq} ackId={AckId} dispatch={Dispatch} ackSent={AckSent} ackArg={AckArg} highWaterMark={Hwm}",
ViewerId, env.Uri, env.PubSeq, frame.AckId, shouldDispatch, ackSent, ackArg, Inbound.HighWaterMark);
}
if (!shouldDispatch) return;
if (FrameEmitted is not null)
@@ -219,6 +272,93 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
}
}
/// <summary>
/// Ack <c>hand</c> events from the client so the client's <c>stockEmitMessageMgr</c>
/// drains and subsequent emits transmit.
/// <para>
/// Wire shape: hand events are <b>not encrypted</b> on the wire — the client's
/// <c>RealTimeNetworkAgent.CreatePackEmitHandData:815-817</c> calls only
/// <c>MessagePackSerializer.Serialize(JsonMapper.ToJson(list))</c>, skipping the
/// <c>CryptAES.encryptForNode</c> wrap that <c>CreatePackEmitData</c> applies to <c>msg</c>
/// events. The msgpack-wrapped string is a JSON array of the form
/// <c>[uri_int, viewerId, udid, pubSeq, ...emit_params]</c> — see
/// <c>EmitMsgUriPack:1456-1458</c> which inserts <c>pubSeq</c> at index 3 of the list
/// for <c>isHandData</c> emits. The dict's top-level <c>pubSeq</c> stays client-local
/// (used by its stockEmitMessageMgr.GetSelectData lookup); it's NOT on the wire.
/// </para>
/// <para>
/// In scripted/Bot mode the server has no opponent to forward touches to; ack-only is
/// correct. PvP-side forwarding semantics are unverified — see
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
/// </para>
/// <para>
/// Fire-and-forget hand frames (TOUCH_URI / SELECT_OBJECT_URI / TURN_END_READY_URI) arrive
/// with no ack-id; we swallow without decoding. Stocked variants (SELECT_SKILL_URI /
/// SLIDE_OBJECT_URI) arrive with an ack-id and must be acked with the body's <c>pubSeq</c>
/// or the client's emit queue softlocks behind them.
/// </para>
/// </summary>
private async Task HandleHandEventAsync(SocketIoFrame frame)
{
if (!frame.AckId.HasValue)
{
// Fire-and-forget; no queue-blocking risk. Swallow without decoding.
return;
}
try
{
// No NodeCrypto.DecryptForNode here — hand events are unencrypted on the wire.
var json = MessagePack.MessagePackSerializer.Deserialize<string>(frame.BinaryAttachments[0]);
if (_diagnosticLogging)
{
_log.LogInformation(
"[hand-rx] viewer={Vid} ackId={AckId} bodyLen={Len} body={Body}",
ViewerId, frame.AckId, json.Length,
json.Length > 200 ? json.Substring(0, 200) + "..." : json);
}
using var doc = System.Text.Json.JsonDocument.Parse(json);
long? pubSeq = null;
var rootKind = doc.RootElement.ValueKind;
if (rootKind == System.Text.Json.JsonValueKind.Array)
{
// Prod shape: [uri_int, viewerId, udid, pubSeq, ...emit_params].
var arr = doc.RootElement;
if (arr.GetArrayLength() > 3
&& arr[3].ValueKind == System.Text.Json.JsonValueKind.Number)
{
pubSeq = arr[3].GetInt64();
}
}
else if (rootKind == System.Text.Json.JsonValueKind.Object
&& doc.RootElement.TryGetProperty("pubSeq", out var psEl)
&& psEl.ValueKind == System.Text.Json.JsonValueKind.Number)
{
// Defensive: dict root with top-level pubSeq isn't what the client sends today,
// but the StockHandData dict shape exists on the client side and a future
// wire-format change could expose it. Cheap to handle.
pubSeq = psEl.GetInt64();
}
if (pubSeq is null)
{
_log.LogWarning(
"RealParticipant viewer={Vid}: 'hand' event ackId={AckId} body has no extractable pubSeq " +
"(rootKind={Kind}, bodyLen={Len}); acking with 0 as fallback.",
ViewerId, frame.AckId, rootKind, json.Length);
await SendSioAckAsync(frame.AckId.Value, 0);
return;
}
await SendSioAckAsync(frame.AckId.Value, pubSeq.Value);
}
catch (Exception ex)
{
_log.LogWarning(ex,
"RealParticipant viewer={Vid}: failed to decode 'hand' event body; not acking. ackId={AckId}",
ViewerId, frame.AckId);
}
}
private async Task HandleAliveEventAsync(SocketIoFrame frame)
{
try
@@ -306,9 +446,26 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
do
{
try { result = await _ws.ReceiveAsync(buffer, ct); }
catch (OperationCanceledException) { return null; }
catch (WebSocketException) { return null; }
if (result.MessageType == WebSocketMessageType.Close) return null;
catch (OperationCanceledException)
{
if (_diagnosticLogging)
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
return null;
}
catch (WebSocketException wsex)
{
if (_diagnosticLogging)
_log.LogWarning(wsex, "[ws-recv-exit] viewer={Vid} reason=WebSocketException wsState={State} errCode={ErrCode}",
ViewerId, _ws.State, wsex.WebSocketErrorCode);
return null;
}
if (result.MessageType == WebSocketMessageType.Close)
{
if (_diagnosticLogging)
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
return null;
}
ms.Write(buffer, 0, result.Count);
} while (!result.EndOfMessage);
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);

View File

@@ -23,12 +23,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
{
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
public MatchContext Context { get; } = new(
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 (matches the
// hardcoded OppoDeckCount that ScriptedProfiles.OpponentMatchedProfile shipped).
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched)
// shipped OppoDeckCount: 30.
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
// BattleStart opponent half: ClassId/CharaId from ScriptedProfiles.OpponentBattleStartProfile.
// BattleStart opponent half (frame[5]): ClassId/CharaId both "8" (neutral test class).
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
// Matched opponent half: cosmetic fields from ScriptedProfiles.OpponentMatchedProfile.
// Matched opponent half (frame[2]): cosmetic fields from the prod capture.
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
BattleType: 0);
@@ -37,9 +37,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
{
// v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
// three-frame burst. Everything else is silently swallowed.
if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
// React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
// Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
// (including TurnEndFinal) is silently swallowed: TurnEndFinal is the player's
// game-end signal and is handled directly by the BattleSession dispatch arm, which
// pushes BattleFinish per-side; the bot doesn't need to react.
if (envelope.Uri is NetworkBattleUri.TurnEnd)
{
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);

View File

@@ -11,23 +11,17 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
public class ArenaTwoPickBattleController : SVSimController
{
private readonly IArenaTwoPickService _svc;
private readonly IMatchingBridge _matching;
private readonly IMatchContextBuilder _matchContextBuilder;
private readonly IMatchingPairUpService _pairUp;
private readonly BattleNodeOptions _battleNodeOptions;
private readonly IMatchingResolver _resolver;
public ArenaTwoPickBattleController(
IArenaTwoPickService svc,
IMatchingBridge matching,
IMatchContextBuilder matchContextBuilder,
IMatchingPairUpService pairUp,
BattleNodeOptions battleNodeOptions)
IMatchingResolver resolver)
{
_svc = svc;
_matching = matching;
_matchContextBuilder = matchContextBuilder;
_pairUp = pairUp;
_battleNodeOptions = battleNodeOptions;
_resolver = resolver;
}
[HttpPost("do_matching")]
@@ -37,59 +31,21 @@ public class ArenaTwoPickBattleController : SVSimController
CancellationToken ct = default)
{
if (!TryGetViewerId(out var vid)) return Unauthorized();
// Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path.
// ASP.NET's default bool binder rejects "1", so do a permissive parse here.
// The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other
// route — it bypasses pair-up for every solo poll, useful when the live client
// (which can't append query params) needs a Scripted match.
var useScripted = (scripted is not null
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)))
|| _battleNodeOptions.SoloDefaultsToScripted;
// Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted
// path. ASP.NET's default bool binder rejects "1", so parse permissively here.
// BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is
// applied inside the resolver.
var scriptedOptIn = scripted is not null
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase));
try
{
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
if (useScripted)
{
var scriptedMatch = _matching.RegisterBattle(
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
p2: null,
SVSim.BattleNode.Sessions.BattleType.Scripted);
return Ok(new DoMatchingResponseDto
{
MatchingState = 3004,
BattleId = scriptedMatch.BattleId,
NodeServerUrl = scriptedMatch.NodeServerUrl,
});
}
var paired = await _pairUp.TryPairAsync(
"arena_two_pick_battle",
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
ct);
if (paired is null)
{
// 3002 = RC_BATTLE_MATCHING_RETRY: client polls again. 3001 is ILLEGAL
// and shows an error dialog on the client side. node_server_url must be
// present (the client's DoMatchingBase.SettingDoMatchingData calls
// .ToString() on it without a Keys.Contains guard); prod sends "" while
// waiting and the real URL only on SUCCEEDED. battle_id stays absent
// (its accessor IS guarded).
return Ok(new DoMatchingResponseDto
{
MatchingState = 3002,
NodeServerUrl = "",
});
}
// Owner (first arriver, cache hit) gets 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER;
// joiner (second arriver who triggered the pair) gets 3004 = RC_BATTLE_MATCHING_SUCCEEDED.
// See PairUpResult docs for why this split is observationally inert in TK2 today.
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct);
return Ok(new DoMatchingResponseDto
{
MatchingState = paired.IsOwner ? 3007 : 3004,
BattleId = paired.Match.BattleId,
NodeServerUrl = paired.Match.NodeServerUrl,
MatchingState = r.MatchingState,
BattleId = r.BattleId,
NodeServerUrl = r.NodeServerUrl,
});
}
catch (ArenaTwoPickException ex)

View File

@@ -23,23 +23,20 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
public sealed class RankBattleController : ControllerBase
{
private readonly IMatchingPairUpService _pairUp;
private readonly IMatchingBridge _bridge;
private readonly IMatchingResolver _resolver;
private readonly IBattleSessionStore _sessionStore;
private readonly IMatchContextBuilder _ctxBuilder;
private readonly IBotRoster _botRoster;
private readonly ILogger<RankBattleController> _log;
public RankBattleController(
IMatchingPairUpService pairUp,
IMatchingBridge bridge,
IMatchingResolver resolver,
IBattleSessionStore sessionStore,
IMatchContextBuilder ctxBuilder,
IBotRoster botRoster,
ILogger<RankBattleController> log)
{
_pairUp = pairUp;
_bridge = bridge;
_resolver = resolver;
_sessionStore = sessionStore;
_ctxBuilder = ctxBuilder;
_botRoster = botRoster;
@@ -135,33 +132,16 @@ public sealed class RankBattleController : ControllerBase
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
}
var paired = await _pairUp.TryPairAsync(mode, new BattlePlayer(vid, ctx), ct);
if (paired is null)
{
// Parked. 3002 RETRY. node_server_url must be present as empty string —
// client's DoMatchingBase parser calls .ToString() without a guard.
return Ok(new DoMatchingResponseDto
{
MatchingState = 3002,
NodeServerUrl = "",
});
}
// Owner cache-pickup → 3007 (PvP) or 3011 (AI fallback).
// Joiner (only PvP) → 3004.
var state = paired switch
{
{ IsAiFallback: true } => 3011,
{ IsOwner: true } => 3007,
_ => 3004,
};
// Rank battle has no ?scripted=1 query opt-in (no live capture has shown such a
// param on the rank URLs). The process-wide BattleNodeOptions.SoloDefaultsToScripted
// toggle is the only scripted entry point and is honored inside the resolver.
var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), scriptedOptIn: false, ct);
return Ok(new DoMatchingResponseDto
{
MatchingState = state,
BattleId = paired.Match.BattleId,
NodeServerUrl = paired.Match.NodeServerUrl,
MatchingState = r.MatchingState,
BattleId = r.BattleId,
NodeServerUrl = r.NodeServerUrl,
// Placeholder per spec § Out of scope — per-battle card-master split is deferred.
CardMasterId = 0,
});

View File

@@ -0,0 +1,54 @@
using SVSim.BattleNode.Bridge;
namespace SVSim.EmulatedEntrypoint.Matching;
/// <summary>
/// Single source of truth for how a <c>/do_matching</c> request is resolved into a wire
/// matching_state + battle_id + node_server_url across every battle family.
/// <para>
/// Lives here (and not on each controller) because the resolution rules are the same
/// regardless of which URL family carried the request:
/// </para>
/// <list type="number">
/// <item>Honor the dev-affordance scripted opt-in (route flag and/or
/// <see cref="BattleNodeOptions.SoloDefaultsToScripted"/>) — bypass pair-up,
/// register a Scripted match, return immediately.</item>
/// <item>Otherwise consult <see cref="IMatchingPairUpService"/> and translate the
/// resulting <see cref="PairUpResult"/> into a wire matching_state per the
/// 3002 / 3004 / 3007 / 3011 vocabulary.</item>
/// </list>
/// <para>
/// Family-specific details (DTO shapes, family-specific request fields like
/// <c>card_master_id</c>, error-mapping like rank-battle's 3001 on a missing deck) stay
/// on the controllers. The resolver only owns the cross-cutting "did the flag win, did
/// pair-up resolve, what's the state code" decision.
/// </para>
/// </summary>
public interface IMatchingResolver
{
/// <param name="mode">
/// The matching-mode key the resolver passes through to
/// <see cref="IMatchingPairUpService.TryPairAsync"/> — one of the
/// <see cref="ModePolicy"/> registry's mode names (e.g. <c>"arena_two_pick_battle"</c>,
/// <c>"rotation_rank_battle"</c>, <c>"unlimited_rank_battle"</c>).
/// </param>
/// <param name="player">Caller's <see cref="BattlePlayer"/> (viewer-id + built MatchContext).</param>
/// <param name="scriptedOptIn">
/// Per-request opt-in from a controller-specific signal (e.g. TK2's <c>?scripted=1</c>
/// query param). OR'd with <see cref="BattleNodeOptions.SoloDefaultsToScripted"/>;
/// either being true short-circuits to a Scripted match.
/// </param>
Task<MatchingResolution> ResolveAsync(
string mode,
BattlePlayer player,
bool scriptedOptIn,
CancellationToken ct);
}
/// <summary>
/// Wire-level outcome of a <c>/do_matching</c> resolution. Always carries a non-null
/// <see cref="NodeServerUrl"/> — empty string while parked (3002), real URL on resolution —
/// because the client's <c>DoMatchingBase.SettingDoMatchingData()</c> calls
/// <c>.ToString()</c> on the wire field without a <c>Keys.Contains</c> guard.
/// </summary>
public sealed record MatchingResolution(int MatchingState, string? BattleId, string NodeServerUrl);

View File

@@ -0,0 +1,63 @@
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
namespace SVSim.EmulatedEntrypoint.Matching;
/// <inheritdoc cref="IMatchingResolver"/>
public sealed class MatchingResolver : IMatchingResolver
{
private readonly IMatchingBridge _bridge;
private readonly IMatchingPairUpService _pairUp;
private readonly BattleNodeOptions _options;
public MatchingResolver(
IMatchingBridge bridge,
IMatchingPairUpService pairUp,
BattleNodeOptions options)
{
_bridge = bridge;
_pairUp = pairUp;
_options = options;
}
public Task<MatchingResolution> ResolveAsync(
string mode,
BattlePlayer player,
bool scriptedOptIn,
CancellationToken ct)
{
// Dev-affordance short-circuit. Either a per-request flag (e.g. ?scripted=1) or the
// process-wide BattleNodeOptions.SoloDefaultsToScripted toggle puts us here.
// Registers a Scripted match (server-side scripted opponent in BattleSession) and
// returns matching_state=3004 SUCCEEDED so the client opens the WS and proceeds.
if (scriptedOptIn || _options.SoloDefaultsToScripted)
{
var m = _bridge.RegisterBattle(player, p2: null, BattleType.Scripted);
return Task.FromResult(new MatchingResolution(3004, m.BattleId, m.NodeServerUrl));
}
return ResolveViaPairUpAsync(mode, player, ct);
}
private async Task<MatchingResolution> ResolveViaPairUpAsync(string mode, BattlePlayer player, CancellationToken ct)
{
var paired = await _pairUp.TryPairAsync(mode, player, ct);
if (paired is null)
{
// Parked. matching_state 3002 RETRY. node_server_url MUST be present as empty
// string (the client unguarded-.ToString()s it before consulting matching_state).
return new MatchingResolution(3002, BattleId: null, "");
}
// 3011 = AI_BATTLE_MATCHING_SUCCEEDED (PvpFirstThenAiFallback policy's threshold fired)
// 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER (first arriver, cache pickup)
// 3004 = RC_BATTLE_MATCHING_SUCCEEDED (joiner — triggered the pair)
var state = paired switch
{
{ IsAiFallback: true } => 3011,
{ IsOwner: true } => 3007,
_ => 3004,
};
return new MatchingResolution(state, paired.Match.BattleId, paired.Match.NodeServerUrl);
}
}

View File

@@ -138,6 +138,10 @@ public class Program
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
}));
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
// Single resolver shared by every /do_matching family controller. Owns the scripted-
// flag short-circuit + the pair-up → matching_state mapping. Singleton: stateless,
// all deps are singletons too.
builder.Services.AddSingleton<IMatchingResolver, MatchingResolver>();
// Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info.
// Transient because BotRoster depends on the transient IGlobalsRepository.
builder.Services.AddTransient<IBotRoster, BotRoster>();
@@ -156,7 +160,7 @@ public class Program
});
var app = builder.Build();
// Update database (skipped for non-relational providers, e.g. InMemory in tests, and
// skipped under the "Testing" environment where the test fixture has already called
// EnsureCreated against a SQLite in-memory DB — the Postgres migrations would fail there).

View File

@@ -6,6 +6,8 @@
}
},
"BattleNode": {
"SoloDefaultsToScripted": false
"SoloDefaultsToScripted": false,
"DiagnosticLogging": false
}
}

View File

@@ -281,9 +281,10 @@ public class BattleNodeFlowTests
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
var aBody = (RawBody)aFinish.Body;
var bBody = (RawBody)bFinish.Body;
// BattleResult.Lose = 0, Win = 1.
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Lose));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
// BattleResult.RetireLose = 106 (retirer), RetireWin = 105 (survivor). Player-
// perspective codes per the FinishBattleEffect trace.
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireLose));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireWin));
}
[Test]
@@ -319,11 +320,11 @@ public class BattleNodeFlowTests
// Abruptly close A's WS (no Retire).
await clientA.DisposeAsync();
// B should receive BattleFinish(Win) within a few seconds.
// B should receive BattleFinish(DisconnectWin) within a few seconds.
var bFinish = await clientB.ReceiveSynchronizeAsync(ct);
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
var bBody = (RawBody)bFinish.Body;
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.DisconnectWin));
// PendingBattle should be evicted by the second arriver's RemovePending.
var store = factory.Services.GetRequiredService<SVSim.BattleNode.Sessions.IBattleSessionStore>();
@@ -368,21 +369,28 @@ public class BattleNodeFlowTests
// NOTE: ConsumeHandshakeAsync is NOT called here. The EIO Open frame is sent inside
// RealParticipant.RunAsync, which only runs once the session is constructed by the
// SECOND arriver. The first arriver who times out never receives that frame — the
// handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, the
// handler's HTTP method returns, and the TestServer-side WS shuts down. ReceiveAsync
// observes the shutdown either by returning a Close message or throwing.
// handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, and
// the polite-close path emits an EIO "1" Close text frame followed by a clean
// WebSocket close handshake before the handler returns.
bool politeFrameObserved = false;
bool closeObserved = false;
var sw = System.Diagnostics.Stopwatch.StartNew();
var buf = new byte[1024];
while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65))
{
try
{
var rr = await wsA.ReceiveAsync(new ArraySegment<byte>(new byte[1024]), ct);
var rr = await wsA.ReceiveAsync(new ArraySegment<byte>(buf), ct);
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
{
closeObserved = true;
break;
}
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Text)
{
var text = System.Text.Encoding.UTF8.GetString(buf, 0, rr.Count);
if (text == "1") politeFrameObserved = true;
}
}
catch
{
@@ -391,6 +399,8 @@ public class BattleNodeFlowTests
break;
}
}
Assert.That(politeFrameObserved, Is.True,
"A's WS should receive an EIO '1' Close text frame before teardown (polite-close contract).");
Assert.That(closeObserved, Is.True,
"A's WS should close (or ReceiveAsync should fail) after the waiting-room timeout.");
wsA.Dispose();

View File

@@ -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 }
}
""";
}

View File

@@ -34,27 +34,17 @@ public class SmallBodiesTests
[Test]
public void BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues()
{
// The wire field is the int code (Win=1); BattleResult uses JsonNumberEnumConverter
// to override the default JsonStringEnumConverter (which would emit "Win" instead).
var body = new BattleFinishBody(Result: BattleResult.Win);
// The wire field is the int RESULT_CODE (LifeWin=101); BattleResult uses
// JsonNumberEnumConverter to override the default JsonStringEnumConverter (which
// would emit "LifeWin" instead).
var body = new BattleFinishBody(Result: BattleResult.LifeWin);
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
Assert.That(node["result"]!.GetValue<int>(), Is.EqualTo(1));
Assert.That(node["result"]!.GetValue<int>(), Is.EqualTo(101));
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
}
[Test]
public void BattleFinishBody_LoseAndConsistency_SerializeAsZeroAndTwo()
{
// Lock the wire values per BattleFinishResponsProcessing's switch (0=LOSE, 2=CONSISTENCY).
var lose = (JsonObject)JsonSerializer.SerializeToNode(new BattleFinishBody(BattleResult.Lose))!;
var consistency = (JsonObject)JsonSerializer.SerializeToNode(new BattleFinishBody(BattleResult.Consistency))!;
Assert.That(lose["result"]!.GetValue<int>(), Is.EqualTo(0));
Assert.That(consistency["result"]!.GetValue<int>(), Is.EqualTo(2));
}
[Test]
public void AlivePushBody_SerializesScsAndOcs_AndDoesNotIncludeResultCode()
{

View File

@@ -82,6 +82,48 @@ public class BattleSessionDispatchTests
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
}
[Test]
public void Scripted_TurnEndFinal_forwards_envelope_and_pushes_paired_BattleFinish()
{
// Unified TurnEndFinal handling: forward the envelope to other (matches prod
// capture battle-traffic_tk2_regular.ndjson:273) + push BattleFinish per-side
// with player-perspective codes (LifeWin to winner, LifeLose to loser).
// In Scripted mode the "loser" is a ScriptedBotParticipant; the loser-side
// BattleFinish push is harmless (bot swallows non-TurnEnd URIs).
var (s, a, b) = NewSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
Assert.That(routes.Count, Is.EqualTo(3),
"TurnEndFinal must produce: forwarded envelope + BattleFinish(LifeWin) to from + BattleFinish(LifeLose) to other.");
// Route 0: forwarded TurnEndFinal envelope to other.
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
// Route 1: BattleFinish(LifeWin) to from (the winner who declared the final turn).
Assert.That(routes[1].Target, Is.SameAs(a));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[1].NoStock, Is.True);
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
Assert.That(winBody.Result, Is.EqualTo(BattleResult.LifeWin),
"Winner gets LifeWin (101) — player-perspective: 'I won by life' → WIN UI.");
// Route 2: BattleFinish(LifeLose) to other (the loser).
Assert.That(routes[2].Target, Is.SameAs(b));
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[2].NoStock, Is.True);
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[2].Frame.Body;
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.LifeLose),
"Loser gets LifeLose (102) — player-perspective: 'I lost by life' → LOSE UI.");
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
}
[Test]
public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase()
{
@@ -154,28 +196,46 @@ public class BattleSessionDispatchTests
}
[Test]
public void Retire_pushes_BattleFinish_no_contest_terminates()
public void Retire_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
{
var (s, a, _) = NewSession();
var (s, a, b) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[0].NoStock, Is.True);
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose),
"Retirer gets RetireLose=106 — player-perspective: 'I lost by retire'.");
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[1].NoStock, Is.True);
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin),
"Survivor gets RetireWin=105. In Scripted mode the bot swallows it; in PvP the opponent renders 'opponent retired'.");
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
public void Kill_pushes_BattleFinish_no_contest_terminates()
public void Kill_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
{
var (s, a, _) = NewSession();
var (s, a, b) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(routes[0].NoStock, Is.True);
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
@@ -360,17 +420,29 @@ public class BattleSessionDispatchTests
}
[Test]
public void Pvp_TurnEndFinal_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
{
// Same unified handling as Scripted — A is the winner, B is the loser.
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
Assert.That(routes.Count, Is.EqualTo(4));
Assert.That(routes.Select(r => r.Frame.Uri).Distinct(),
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
Assert.That(routes.Count, Is.EqualTo(3));
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
Assert.That(routes[1].Target, Is.SameAs(a));
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.LifeWin));
Assert.That(routes[2].Target, Is.SameAs(b));
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
}
[Test]
@@ -386,7 +458,7 @@ public class BattleSessionDispatchTests
}
[Test]
public void Pvp_Retire_from_A_pushes_BattleFinish_Lose_to_A_and_Win_to_B()
public void Pvp_Retire_from_A_pushes_RetireLose_to_A_and_RetireWin_to_B()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
@@ -398,9 +470,9 @@ public class BattleSessionDispatchTests
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Lose));
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Win));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
Assert.That(aRoute.NoStock, Is.True);
Assert.That(bRoute.NoStock, Is.True);
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
@@ -420,15 +492,20 @@ public class BattleSessionDispatchTests
}
[Test]
public void Scripted_Retire_still_pushes_BattleFinish_Win_to_sender_only()
public void Scripted_Retire_pushes_RetireLose_to_player_and_RetireWin_to_bot()
{
// Regression guard — Phase 1 behavior preserved for Scripted.
var (s, a, _) = NewSession();
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
// is a ScriptedBotParticipant; its loser-side push is swallowed (it only reacts
// to TurnEnd). The wire-correct codes are still emitted in case future work
// wants to inspect them or run a real two-real-participant session.
var (s, a, b) = NewSession();
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
}
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
@@ -558,9 +635,11 @@ public class BattleSessionDispatchTests
}
[Test]
public void Bot_Retire_pushes_BattleFinish_Win_to_sender()
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
{
var (s, a, _) = NewBotSession();
// Unified Retire/Kill dispatch — same paired push as Scripted and PvP.
// NoOpBotParticipant swallows its push.
var (s, a, b) = NewBotSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
@@ -568,9 +647,11 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(routes[1].Target, Is.SameAs(b));
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
}
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()

View File

@@ -0,0 +1,154 @@
using System.Net.WebSockets;
using System.Text;
using MessagePack;
using Microsoft.Extensions.Logging.Abstractions;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions;
using SVSim.BattleNode.Sessions.Participants;
using SVSim.BattleNode.Wire;
using SVSim.UnitTests.BattleNode.Infrastructure;
namespace SVSim.UnitTests.BattleNode.Sessions.Participants;
/// <summary>
/// Regression tests for the <c>"hand"</c> SIO event handler. The wire shape verified at
/// <c>RealTimeNetworkAgent.CreatePackEmitHandData:815-817</c>:
/// <code>
/// return MessagePackSerializer.Serialize(JsonMapper.ToJson(info)); // info = List&lt;object&gt;, NOT encrypted
/// </code>
/// is the source of truth this test must match. (An earlier version of this test
/// wrapped the body in an encrypted dict shape — that was wrong and shipped a handler
/// that softlocked in prod despite passing the test. See
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.)
/// </summary>
[TestFixture]
public class RealParticipantHandEventTests
{
[Test]
public async Task Stocked_hand_event_acks_with_array_index3_pubSeq()
{
// Prod wire shape per EmitMsgUriPack:1454-1458 — Insert(3, num) puts pubSeq at index 3:
// [uri_int, viewerId, udid, pubSeq, ...select-skill params]
const long expectedPubSeq = 42L;
var bodyJson = $"[2,906243102,\"d08367be-1152-4009-aaaf-2d47d1d9112c\",{expectedPubSeq},1,false,false]";
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 906_243_102L, FixtureCtx(),
NullLogger<RealParticipant>.Instance);
EnqueueHandFrame(ws, ackId: 26, bodyJson: bodyJson);
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
var ackFrame = FindAckFrame(ws, ackId: 26);
Assert.That(ackFrame, Is.Not.Null,
$"Expected an SIO Ack frame for ackId=26 in outbound sends; got: [{string.Join(", ", AllTextSends(ws))}]");
Assert.That(ackFrame, Does.Contain($"[{expectedPubSeq}]"),
"Ack arg must echo the body's pubSeq (array index 3) so client's stockEmitMessageMgr.GetSelectData succeeds.");
}
[Test]
public async Task Stocked_hand_event_with_dict_root_acks_with_top_level_pubSeq()
{
// Defensive: not what the client sends today, but the StockHandData dict shape
// exists client-side and could surface on the wire with a future format change.
const long expectedPubSeq = 17L;
var bodyJson = $"{{\"StockHandData\":[2,1,\"u\",{expectedPubSeq}],\"try\":0,\"pubSeq\":{expectedPubSeq}}}";
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger<RealParticipant>.Instance);
EnqueueHandFrame(ws, ackId: 33, bodyJson: bodyJson);
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
var ackFrame = FindAckFrame(ws, ackId: 33);
Assert.That(ackFrame, Is.Not.Null);
Assert.That(ackFrame, Does.Contain($"[{expectedPubSeq}]"));
}
[Test]
public async Task Hand_event_without_ackId_is_swallowed_silently_no_ack_sent()
{
// Fire-and-forget hand emits (TOUCH_URI, SELECT_OBJECT_URI, TURN_END_READY_URI) arrive
// without an ack-id and don't block the client's emit queue. We should swallow them
// without decoding or acking.
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger<RealParticipant>.Instance);
EnqueueHandFrame(ws, ackId: null, bodyJson: "[1,1,\"u\",0,0]");
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
var ackFrames = AllTextSends(ws).Where(s => s.StartsWith("43")).ToList();
Assert.That(ackFrames, Is.Empty,
$"No-ack-id hand frame must not produce an Ack; got: [{string.Join(", ", ackFrames)}]");
}
[Test]
public async Task Hand_event_with_unparseable_pubSeq_position_falls_back_to_ack_arg_0()
{
// If a stocked hand frame ever arrives with an array shorter than 4 elements (or a
// non-numeric index 3), we still ack so the client doesn't softlock — but with
// arg=0. The client's GetSelectData lookup misses and OnAck fires with null
// selectData, which is a normal cache-miss path (not a deadlock).
var bodyJson = "[2,1,\"u\"]"; // length 3, no index-3 pubSeq
var ws = new TestWebSocket();
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger<RealParticipant>.Instance);
EnqueueHandFrame(ws, ackId: 99, bodyJson: bodyJson);
ws.CompleteIncoming();
await p.RunAsync(CancellationToken.None);
var ackFrame = FindAckFrame(ws, ackId: 99);
Assert.That(ackFrame, Is.Not.Null,
"Malformed hand body should still ack (arg=0), not silently swallow.");
Assert.That(ackFrame, Does.Contain("[0]"),
"Fallback ack arg should be 0.");
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
/// <summary>
/// Enqueue an SIO BinaryEvent("hand", {placeholder}) text frame followed by its single
/// binary attachment (msgpack-string of the raw JSON, <b>not</b> encrypted —
/// CreatePackEmitHandData:815-817 does not call CryptAES.encryptForNode).
/// </summary>
private static void EnqueueHandFrame(TestWebSocket ws, int? ackId, string bodyJson)
{
var ackPart = ackId.HasValue ? ackId.Value.ToString() : "";
var text = $"451-{ackPart}[\"hand\",{{\"_placeholder\":true,\"num\":0}}]";
ws.EnqueueIncoming(Encoding.UTF8.GetBytes(text), WebSocketMessageType.Text);
// Binary attachment: EIO Message prefix (0x04) + msgpack-string(bodyJson).
var msgpackBytes = MessagePackSerializer.Serialize(bodyJson);
var prefixed = new byte[msgpackBytes.Length + 1];
prefixed[0] = (byte)EngineIoPacketType.Message;
Buffer.BlockCopy(msgpackBytes, 0, prefixed, 1, msgpackBytes.Length);
ws.EnqueueIncoming(prefixed, WebSocketMessageType.Binary);
}
private static IEnumerable<string> AllTextSends(TestWebSocket ws) =>
ws.Sends
.Where(f => f.Type == WebSocketMessageType.Text)
.Select(f => Encoding.UTF8.GetString(f.Payload));
private static string? FindAckFrame(TestWebSocket ws, int ackId) =>
AllTextSends(ws).FirstOrDefault(s => s.StartsWith($"43{ackId}["));
private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11);
}

View File

@@ -28,22 +28,20 @@ public class ScriptedBotParticipantTests
}
[Test]
public async Task PushAsync_TurnEndFinal_fires_three_FrameEmitted_in_order()
public async Task PushAsync_TurnEndFinal_does_NOT_fire_burst()
{
// Same burst as TurnEnd — TurnEndFinal is the game-ending variant but the
// bot's response shape is unchanged for v1.2 behaviour preservation.
// TurnEndFinal is the game-end signal — owned by BattleSession's TurnEndFinal
// dispatch arm, which pushes BattleFinish per-side. The bot no longer reacts to
// it; reacting would race the BattleFinish with the no-longer-needed 3-frame
// burst. Only regular TurnEnd triggers the burst.
var p = new ScriptedBotParticipant();
var emitted = new List<NetworkBattleUri>();
p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; };
var fired = 0;
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
Assert.That(emitted, Is.EqualTo(new[]
{
NetworkBattleUri.TurnStart,
NetworkBattleUri.TurnEnd,
NetworkBattleUri.Judge,
}));
Assert.That(fired, Is.EqualTo(0),
"TurnEndFinal must not trigger the bot's burst — the dispatch arm pushes BattleFinish directly.");
}
[Test]

View File

@@ -0,0 +1,118 @@
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.Database;
using SVSim.Database.Enums;
using SVSim.Database.Models;
using SVSim.UnitTests.Infrastructure;
namespace SVSim.UnitTests.Controllers;
/// <summary>
/// Cross-family contract for <c>/do_matching</c>. The single load-bearing assertion: when
/// <see cref="BattleNodeOptions.SoloDefaultsToScripted"/> is <c>true</c>, every family's
/// first poll must bypass pair-up and return a SUCCEEDED matching_state with a battle_id +
/// node_server_url — not the 3002 RETRY of the normal pair-up path.
/// <para>
/// Adding a new family is the failure trigger for this test: the new controller MUST route
/// through <see cref="SVSim.EmulatedEntrypoint.Matching.IMatchingResolver"/>, or this test
/// fails. That's the point — the test enforces "stay in line" across families.
/// </para>
/// </summary>
[TestFixture]
public class DoMatchingContractTests
{
private static readonly object DoMatchingBody = new
{
deck_no = 1L,
need_init = 1,
log = 1,
excluded_field_id_list = Array.Empty<long>(),
use_stage_select = 1,
is_default_skin = 0,
viewer_id = "0",
steam_id = 0,
steam_session_ticket = "",
};
[TestCase("/arena_two_pick_battle/do_matching", FamilyKind.TwoPick)]
[TestCase("/rotation_rank_battle/do_matching", FamilyKind.RankRotation)]
[TestCase("/unlimited_rank_battle/do_matching", FamilyKind.RankUnlimited)]
public async Task SoloDefaultsToScripted_short_circuits_every_family_to_immediate_SUCCEEDED(string url, FamilyKind family)
{
await using var factory = new SVSimTestFactory();
factory.Services.GetRequiredService<BattleNodeOptions>().SoloDefaultsToScripted = true;
var viewerId = await factory.SeedViewerAsync();
await SetupFamilyAsync(factory, viewerId, family);
using var client = factory.CreateAuthenticatedClient(viewerId);
var resp = await client.PostAsJsonAsync(url, DoMatchingBody);
Assert.That(resp.IsSuccessStatusCode, Is.True, $"Expected 2xx from {url}, got {resp.StatusCode}.");
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
var root = doc.RootElement;
var state = root.GetProperty("matching_state").GetInt32();
Assert.That(state, Is.Not.EqualTo(3002),
$"{url}: SoloDefaultsToScripted=true must bypass pair-up; saw matching_state=3002 RETRY which means the family didn't honor the flag (probably forgot to route through IMatchingResolver).");
Assert.That(state, Is.AnyOf(3004, 3007, 3011),
$"{url}: matching_state must be SUCCEEDED (3004), SUCCEEDED_OWNER (3007), or AI_SUCCEEDED (3011) — got {state}.");
Assert.That(root.GetProperty("battle_id").GetString(), Is.Not.Null.And.Not.Empty,
$"{url}: SUCCEEDED responses must carry battle_id.");
Assert.That(root.GetProperty("node_server_url").GetString(), Does.Contain("/socket.io/"),
$"{url}: node_server_url must point at the WS endpoint.");
}
// Each family has different prerequisites — TK2 needs an active draft run, rank needs
// a deck for the requested format. The factory's seeders are sufficient for both.
public enum FamilyKind { TwoPick, RankRotation, RankUnlimited }
private static async Task SetupFamilyAsync(SVSimTestFactory factory, long viewerId, FamilyKind family)
{
switch (family)
{
case FamilyKind.TwoPick:
await SeedCompleteTwoPickRunAsync(factory, viewerId);
break;
case FamilyKind.RankRotation:
await factory.SeedGlobalsAsync();
await factory.SeedDeckAsync(viewerId, Format.Rotation, number: 1);
break;
case FamilyKind.RankUnlimited:
await factory.SeedGlobalsAsync();
await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1);
break;
default:
throw new ArgumentOutOfRangeException(nameof(family));
}
}
// Mirrors ArenaTwoPickBattleControllerTests.SeedCompleteTwoPickRunAsync. Duplicated
// rather than promoted because the original is a private static there and only this
// test class needs to share it cross-family today; promote if a third caller surfaces.
private static async Task SeedCompleteTwoPickRunAsync(SVSimTestFactory factory, long viewerId)
{
using var scope = factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SVSimDbContext>();
var deck = Enumerable.Range(1, 30).Select(i => 100_011_000L + i).ToList();
db.ViewerArenaTwoPickRuns.Add(new ViewerArenaTwoPickRun
{
ViewerId = viewerId,
EntryId = 1,
ClassId = 1,
LeaderSkinId = 1,
SelectedCardIdsJson = JsonSerializer.Serialize(deck),
IsSelectCompleted = true,
MaxBattleCount = 5,
CandidateClassIdsJson = "[1,2,3]",
PendingPickSetsJson = "[]",
ResultListJson = "[]",
NextCandidateId = 1,
});
await db.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,129 @@
using Moq;
using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Sessions;
using SVSim.EmulatedEntrypoint.Matching;
namespace SVSim.UnitTests.Matching;
/// <summary>
/// Per-test locals (no fixture-level fields) because the assembly runs with
/// <c>[Parallelizable(ParallelScope.All)]</c> — shared <c>_resolver</c>/<c>_bridge</c>
/// fields would race across concurrent tests in this fixture.
/// </summary>
[TestFixture]
public class MatchingResolverTests
{
private sealed record Harness(
Mock<IMatchingBridge> Bridge,
Mock<IMatchingPairUpService> PairUp,
BattleNodeOptions Options,
MatchingResolver Resolver);
private static Harness BuildHarness()
{
var bridge = new Mock<IMatchingBridge>(MockBehavior.Strict);
var pairUp = new Mock<IMatchingPairUpService>(MockBehavior.Strict);
var options = new BattleNodeOptions { NodeServerUrl = "localhost:5148/socket.io/" };
return new Harness(bridge, pairUp, options, new MatchingResolver(bridge.Object, pairUp.Object, options));
}
private static BattlePlayer Player(long vid = 1) =>
new(vid, new MatchContext(
SelfDeckCardIds: Array.Empty<long>(), ClassId: "0", CharaId: "0",
CardMasterName: "card_master_node_10015",
CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11));
[Test]
public async Task When_scriptedOptIn_is_true_registers_Scripted_and_returns_3004()
{
var h = BuildHarness();
var player = Player();
h.Bridge.Setup(b => b.RegisterBattle(player, null, BattleType.Scripted))
.Returns(new PendingMatch("bid-scripted", "node.local/socket.io/"));
var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, scriptedOptIn: true, default);
Assert.That(r.MatchingState, Is.EqualTo(3004));
Assert.That(r.BattleId, Is.EqualTo("bid-scripted"));
Assert.That(r.NodeServerUrl, Is.EqualTo("node.local/socket.io/"));
h.Bridge.VerifyAll();
h.PairUp.Verify(p => p.TryPairAsync(It.IsAny<string>(), It.IsAny<BattlePlayer>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task When_options_SoloDefaultsToScripted_is_true_registers_Scripted_for_any_mode()
{
// Cross-family contract: the process-wide flag overrides pair-up for every mode,
// not just TK2.
var h = BuildHarness();
h.Options.SoloDefaultsToScripted = true;
var player = Player();
h.Bridge.Setup(b => b.RegisterBattle(player, null, BattleType.Scripted))
.Returns(new PendingMatch("bid-rank-scripted", "node.local/socket.io/"));
var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default);
Assert.That(r.MatchingState, Is.EqualTo(3004));
Assert.That(r.BattleId, Is.EqualTo("bid-rank-scripted"));
h.PairUp.Verify(p => p.TryPairAsync(It.IsAny<string>(), It.IsAny<BattlePlayer>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url()
{
var h = BuildHarness();
var player = Player();
h.PairUp.Setup(p => p.TryPairAsync("arena_two_pick_battle", player, It.IsAny<CancellationToken>()))
.ReturnsAsync((PairUpResult?)null);
var r = await h.Resolver.ResolveAsync("arena_two_pick_battle", player, scriptedOptIn: false, default);
Assert.That(r.MatchingState, Is.EqualTo(3002));
Assert.That(r.BattleId, Is.Null);
Assert.That(r.NodeServerUrl, Is.EqualTo(""), "Empty string (not null) — client unguarded-.ToString()s it.");
h.Bridge.Verify(b => b.RegisterBattle(It.IsAny<BattlePlayer>(), It.IsAny<BattlePlayer?>(), It.IsAny<BattleType>()), Times.Never);
}
[Test]
public async Task Pair_owner_role_returns_3007()
{
var h = BuildHarness();
var player = Player();
h.PairUp.Setup(p => p.TryPairAsync("rotation_rank_battle", player, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PairUpResult(new PendingMatch("bid-x", "node.local/socket.io/"), IsOwner: true, IsAiFallback: false));
var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default);
Assert.That(r.MatchingState, Is.EqualTo(3007));
Assert.That(r.BattleId, Is.EqualTo("bid-x"));
}
[Test]
public async Task Pair_joiner_role_returns_3004()
{
var h = BuildHarness();
var player = Player();
h.PairUp.Setup(p => p.TryPairAsync("rotation_rank_battle", player, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PairUpResult(new PendingMatch("bid-x", "node.local/socket.io/"), IsOwner: false, IsAiFallback: false));
var r = await h.Resolver.ResolveAsync("rotation_rank_battle", player, scriptedOptIn: false, default);
Assert.That(r.MatchingState, Is.EqualTo(3004));
}
[Test]
public async Task AI_fallback_returns_3011_regardless_of_owner_flag()
{
// IsAiFallback wins the switch even if IsOwner is also true (the resolver's first arm).
var h = BuildHarness();
var player = Player();
h.PairUp.Setup(p => p.TryPairAsync("unlimited_rank_battle", player, It.IsAny<CancellationToken>()))
.ReturnsAsync(new PairUpResult(new PendingMatch("bid-ai", "node.local/socket.io/"), IsOwner: true, IsAiFallback: true));
var r = await h.Resolver.ResolveAsync("unlimited_rank_battle", player, scriptedOptIn: false, default);
Assert.That(r.MatchingState, Is.EqualTo(3011));
}
}