Compare commits
13 Commits
5c4e427fab
...
fb1e91cdf1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1e91cdf1 | ||
|
|
c551d7b05e | ||
|
|
d76b96b339 | ||
|
|
a198174ede | ||
|
|
0a8a84b2cc | ||
|
|
1685b509c3 | ||
|
|
ee23985055 | ||
|
|
c7e61c6f8d | ||
|
|
8f270a87f0 | ||
|
|
9fc1d055d8 | ||
|
|
672a89ed46 | ||
|
|
9f11896f7b | ||
|
|
a6b9a942ab |
@@ -26,4 +26,18 @@ public sealed class BattleNodeOptions
|
|||||||
/// is the only way to get PvP behavior.</para>
|
/// is the only way to get PvP behavior.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool SoloDefaultsToScripted { get; set; } = false;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using SVSim.BattleNode.Bridge;
|
using SVSim.BattleNode.Bridge;
|
||||||
@@ -122,7 +124,7 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
{
|
{
|
||||||
_store.RemovePending(battleId);
|
_store.RemovePending(battleId);
|
||||||
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||||
_loggerFactory.CreateLogger<RealParticipant>());
|
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||||
var scriptedBot = new ScriptedBotParticipant();
|
var scriptedBot = new ScriptedBotParticipant();
|
||||||
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
||||||
_loggerFactory.CreateLogger<BattleSession>());
|
_loggerFactory.CreateLogger<BattleSession>());
|
||||||
@@ -135,7 +137,7 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
||||||
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
|
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
|
||||||
var self = new RealParticipant(ws, viewerId, selfCtx,
|
var self = new RealParticipant(ws, viewerId, selfCtx,
|
||||||
_loggerFactory.CreateLogger<RealParticipant>());
|
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||||
|
|
||||||
var firstArriver = _waitingRoom.Pair(battleId, self);
|
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.",
|
"PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.",
|
||||||
battleId);
|
battleId);
|
||||||
_store.RemovePending(battleId);
|
_store.RemovePending(battleId);
|
||||||
|
await TryPoliteCloseAsync(ws, "waiting-room timeout", battleId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Retry succeeded — we're the de-facto second arriver now. Own the session.
|
// 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.
|
// earlier isP1/isP2 check has already rejected viewer mismatches.
|
||||||
_store.RemovePending(battleId);
|
_store.RemovePending(battleId);
|
||||||
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
|
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||||
_loggerFactory.CreateLogger<RealParticipant>());
|
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||||
var noopBot = new NoOpBotParticipant();
|
var noopBot = new NoOpBotParticipant();
|
||||||
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
|
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
|
||||||
_loggerFactory.CreateLogger<BattleSession>());
|
_loggerFactory.CreateLogger<BattleSession>());
|
||||||
@@ -209,6 +212,7 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
_log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId);
|
_log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId);
|
||||||
|
await TryPoliteCloseAsync(ws, $"unknown BattleType={pending.Type}", battleId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,4 +223,37 @@ public sealed class BattleNodeWebSocketHandler
|
|||||||
if (!string.IsNullOrEmpty(header)) return header;
|
if (!string.IsNullOrEmpty(header)) return header;
|
||||||
return ctx.Request.Query[name].ToString();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using SVSim.BattleNode.Protocol.Bodies;
|
|
||||||
|
|
||||||
namespace SVSim.BattleNode.Lifecycle;
|
namespace SVSim.BattleNode.Lifecycle;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -15,32 +13,11 @@ internal static class ScriptedProfiles
|
|||||||
// From frame[2] (Matched).
|
// From frame[2] (Matched).
|
||||||
public const long BattleSeed = 17_548_138L;
|
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 frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
|
||||||
// from real per-viewer state needs a TK2 rank/battle-point tracker.
|
// from real per-viewer state needs a TK2 rank/battle-point tracker.
|
||||||
public const string PlayerRank = "10";
|
public const string PlayerRank = "10";
|
||||||
public const string PlayerBattlePoint = "6270";
|
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
|
// From frame[8] (Ready). Provenance is "what prod sent"; the client
|
||||||
// doesn't validate, but echoing matches the capture protects against
|
// doesn't validate, but echoing matches the capture protects against
|
||||||
// a regression on a future tightening.
|
// a regression on a future tightening.
|
||||||
|
|||||||
@@ -1,19 +1,63 @@
|
|||||||
namespace SVSim.BattleNode.Protocol;
|
namespace SVSim.BattleNode.Protocol;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wire value of <c>result</c> on a BattleFinish frame. The client's
|
/// Wire value of <c>result</c> on a WS <c>BattleFinish</c> frame.
|
||||||
/// <c>BattleFinishResponsProcessing</c> switch maps these as:
|
/// <para>
|
||||||
/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch).
|
/// Maps to the client's <c>NetworkBattleReceiver.RESULT_CODE</c> enum at
|
||||||
/// </summary>
|
/// <c>NetworkBattleReceiver.cs:963-986</c>. Names are <b>from the player's perspective</b>:
|
||||||
/// <remarks>
|
/// <c>LifeWin</c> = "I won by life", <c>LifeLose</c> = "I lost by life". Verified
|
||||||
/// This is NOT the same as the client's in-memory <c>BATTLE_RESULT_TYPE</c> enum
|
/// end-to-end via the path
|
||||||
/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0.
|
/// <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
|
/// Always serialize as the int value, not the name; see the
|
||||||
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
|
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
|
||||||
/// </remarks>
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
public enum BattleResult
|
public enum BattleResult
|
||||||
{
|
{
|
||||||
Lose = 0,
|
/// <summary>Player won by reducing opponent's life to 0. Pushed to the winner
|
||||||
Win = 1,
|
/// on <c>TurnEndFinal</c>. Routes through the client switch to
|
||||||
Consistency = 2,
|
/// <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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@ internal static class WireConstants
|
|||||||
/// <summary>SIO event name for Gungnir keepalive frames (both directions).</summary>
|
/// <summary>SIO event name for Gungnir keepalive frames (both directions).</summary>
|
||||||
public const string AliveEvent = "alive";
|
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>
|
/// <summary>
|
||||||
/// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
|
/// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
|
||||||
/// real per-request UUID; the client doesn't validate it.
|
/// real per-request UUID; the client doesn't validate it.
|
||||||
|
|||||||
@@ -1,142 +1,21 @@
|
|||||||
# SVSim.BattleNode
|
# SVSim.BattleNode
|
||||||
|
|
||||||
Socket.IO node-server scaffolding for in-battle traffic. Implements the second of the prod 4-server topology — the realtime channel that handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` / `TurnEnd` between the client and a server-side opponent.
|
Socket.IO node-server emulation for in-battle real-time traffic — the second of prod's 4-server
|
||||||
|
topology. Handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` /
|
||||||
|
`TurnEnd` between a client and a server-side opponent, for TK2 PvP and AI rank battles.
|
||||||
|
|
||||||
**v1 scope** is "scripted thin sequencer": the server accepts a connection, walks a hand-rolled lifecycle from `InitNetwork` to mulligan + first turn + opponent TurnStart, then sits at the opponent's-turn screen indefinitely. No real opponent, no `battleCode` validation, no recovery. v2 work targets each of those.
|
## Documentation lives in the outer repo
|
||||||
|
|
||||||
The library has **no dependency on `SVSim.EmulatedEntrypoint`**. It exposes one DI seam (`IMatchingBridge`) and one ASP.NET Core integration surface (`AddBattleNode` / `UseBattleNode`). Pulling the node into a separate process later is one interface and one Kestrel binding.
|
This project's canonical reference is a single hub doc in the **outer** SVSim repo (this directory
|
||||||
|
is an inner git repo, so the doc isn't tracked alongside the code):
|
||||||
|
|
||||||
## Architecture
|
→ **`docs/battle-node.md`** (from the SVSim root) — architecture, the dispatch matrix by battle
|
||||||
|
type, connect handshake + crypto, `BattleFinish` wire-result semantics, SIO/EIO event coverage,
|
||||||
|
reliability (pubSeq/playSeq/Gungnir), wire-format gotchas, where-to-extend, the manual smoke
|
||||||
|
walkthrough, and the consolidated open-items list.
|
||||||
|
|
||||||
```
|
Relative path from here: [`../../../docs/battle-node.md`](../../../docs/battle-node.md).
|
||||||
SVSim.BattleNode/
|
|
||||||
├─ Bridge/ IMatchingBridge — what /do_matching calls to mint a battle id + node URL
|
|
||||||
├─ Hosting/ ASP.NET Core extensions + the /socket.io/ endpoint handler
|
|
||||||
├─ Lifecycle/ ScriptedLifecycle — the v1 hand-rolled Matched/BattleStart/Deal/Swap/Ready frames
|
|
||||||
├─ Protocol/ MsgEnvelope, NetworkBattleUri enum, msgpack ↔ envelope codec
|
|
||||||
├─ Reliability/ InboundTracker (pubSeq dedup), OutboundSequencer (playSeq archive), Gungnir
|
|
||||||
├─ Sessions/ BattleSession (per-connection state + WS pump), IBattleSessionStore
|
|
||||||
└─ Wire/ EIO3 framing, SIO2 framing, NodeCrypto (AES-256-CBC)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Connect handshake (verified end-to-end against the real client)
|
Detailed per-URI wire shapes are in `docs/api-spec/in-battle/`; the hub links into them.
|
||||||
|
|
||||||
```
|
Keep `docs/battle-node.md` updated in the same change whenever you alter node behavior.
|
||||||
┌────────┐ ┌────────────┐
|
|
||||||
│ Client │ │ BattleNode │
|
|
||||||
└────┬───┘ └──────┬─────┘
|
|
||||||
│ │
|
|
||||||
│ HTTP POST /arena_two_pick_battle/do_matching │ (HTTP host)
|
|
||||||
├──────────────────────────────────────────────────────────────►│
|
|
||||||
│ ◄── { matching_state:3004, battle_id, node_server_url, │
|
|
||||||
│ card_master_id, ... } │
|
|
||||||
│ │
|
|
||||||
│ WS upgrade ws://<node>/socket.io/ │
|
|
||||||
│ headers: BattleId, viewerId=encryptForNode(uid) │
|
|
||||||
├──────────────────────────────────────────────────────────────►│ AcceptWebSocketAsync
|
|
||||||
│ ◄── EIO3 Open 0{sid,upgrades:[],pingInterval,pingTimeout} │
|
|
||||||
│ │
|
|
||||||
│ msg: InitNetwork (cat=99/general) │
|
|
||||||
├──────────────────────────────────────────────────────────────►│
|
|
||||||
│ ◄── synchronize: InitNetwork{resultCode:1} │
|
|
||||||
│ │
|
|
||||||
│ MatchingInitBattle: status=Connect; subscribe receiver │
|
|
||||||
│ msg: InitBattle (cat=2/matching) │
|
|
||||||
├──────────────────────────────────────────────────────────────►│
|
|
||||||
│ ◄── synchronize: Matched{selfInfo,oppoInfo,selfDeck,bid} │
|
|
||||||
│ │
|
|
||||||
│ client loads decks/scene │
|
|
||||||
│ msg: Loaded │
|
|
||||||
├──────────────────────────────────────────────────────────────►│
|
|
||||||
│ ◄── synchronize: BattleStart{turnState,battleType,...} │
|
|
||||||
│ ◄── synchronize: Deal{self,oppo} │
|
|
||||||
│ │
|
|
||||||
│ mulligan UI; player chooses cards to swap │
|
|
||||||
│ msg: Swap{idxList:[...]} │
|
|
||||||
├──────────────────────────────────────────────────────────────►│
|
|
||||||
│ ◄── synchronize: Swap{self:[post-mulligan hand]} │
|
|
||||||
│ ◄── synchronize: Ready{self,oppo,idxChangeSeed,spin} │
|
|
||||||
│ │
|
|
||||||
│ turn 1: TurnStart, PlayActions, ..., TurnEnd │
|
|
||||||
├──────────────────────────────────────────────────────────────►│
|
|
||||||
│ ◄── synchronize: TurnStart{spin} (opponent turn signal) │
|
|
||||||
│ │
|
|
||||||
│ sits at "Opponent's turn…" — v1 stopping point │
|
|
||||||
```
|
|
||||||
|
|
||||||
Each push from us carries a contiguous `playSeq`; client-emit `pubSeq` is echoed back via the Socket.IO ack callback. `Gungnir` runs a 5s alive heartbeat in parallel reporting `scs:ONLINE,ocs:ONLINE`.
|
|
||||||
|
|
||||||
## Wire-format gotchas (discovered during v1 smoke)
|
|
||||||
|
|
||||||
These are not in the original protocol docs and tripped us during the smoke walkthrough — leaving them here so the next reader doesn't repeat the diagnosis.
|
|
||||||
|
|
||||||
| Spec said | Actual wire | Where it shows up |
|
|
||||||
|---|---|---|
|
|
||||||
| `AdditionalQueryParams` on the WS upgrade | **HTTP request headers**, not query string. BestHTTP misnames the API. | `BattleNodeWebSocketHandler.ReadCredential` reads `BattleId` / `viewerId` from headers first, query as fallback (for tests). |
|
|
||||||
| `node_server_url` ws://host:port | `host:port/socket.io/` — **no scheme prefix**, **path included**. | `BattleNodeOptions.NodeServerUrl` default + `do_matching` response. |
|
|
||||||
| `card_master_id` optional | **Required** when `matching_state ∈ {3004,3007,3011}` — no `Keys.Contains` guard client-side. | Added to `DoMatchingResponseDto` with default `1`. |
|
|
||||||
| `resultCode` optional on pushes | **Required = 1** on every scripted synchronize frame; missing means "drop in error handler". | `ScriptedLifecycle.EnvelopeForPush` injects it. |
|
|
||||||
| Matched in response to InitNetwork | **InitBattle**. Matched in response to InitNetwork lands before the client's matching handler is subscribed and silently drops. | See dispatch in `BattleSession.ComputeResponses`. |
|
|
||||||
| WS binary frames carry raw msgpack | EIO3 prefixes binary frames with `0x04` (Message type byte), same as the leading digit on text frames. | `BattleSession.RunAsync` strips on read; `EncodeAndSendAsync` prepends on send. |
|
|
||||||
|
|
||||||
There's also a JSON parsing pitfall worth knowing about (and that broke the mulligan): the inline conditional `el.TryGetInt64(out var l) ? l : el.GetDouble()` unifies its branches to the common implicit-convertible type. Since `long → double` is implicit, the long silently widens to double, and `OfType<long>` downstream drops every entry. See `MsgEnvelope.ParseNumber` for the fix — keep number parsing in a separate method so each branch boxes its own runtime type.
|
|
||||||
|
|
||||||
## v1 scripted opponent — what the client sees
|
|
||||||
|
|
||||||
The player half of `Matched` / `BattleStart` reads from a `MatchContext` assembled in
|
|
||||||
`SVSim.EmulatedEntrypoint/Services/MatchContextBuilder` from the viewer's TK2 run + equipped
|
|
||||||
cosmetics + config — so the mulligan renders the real drafted deck, drafted class/leader,
|
|
||||||
and equipped emblem/degree. The opponent half stays scripted in `ScriptedProfiles`:
|
|
||||||
|
|
||||||
- **Opponent** is a fixed silhouette: `classId="8"`, JPN sleeve/emblem/degree, viewer id `999999999`.
|
|
||||||
- **Battle seed** is `17548138L` in both info blocks (the seed is *shared* per battle per the spec).
|
|
||||||
- **Mulligan** does real card replacement: any idx in your `idxList` is swapped for the next unused deck idx (`1..3` dealt, so `4..30` are pool).
|
|
||||||
- **Opponent's turn** never actually does anything — we push a single `TurnStart{spin:100}` after your `TurnEnd` so the UI transitions to the opponent-turn display, then sit.
|
|
||||||
|
|
||||||
A few player-side fields are still hardcoded pending a follow-up slice — `Rank`, `BattlePoint`,
|
|
||||||
`cardMasterName`, `fieldId`, and the per-battle RNG seed. See the spec's §Deferred plumbing
|
|
||||||
table at `docs/superpowers/specs/2026-06-01-battle-node-real-drafted-deck-design.md` for
|
|
||||||
what each needs.
|
|
||||||
|
|
||||||
## Where to extend
|
|
||||||
|
|
||||||
| You want to | Touch |
|
|
||||||
|---|---|
|
|
||||||
| Wire a new mode's `do_matching` (rank, free, open-room, …) | Add one `BuildFor<Mode>Async(viewerId, …)` method to `IMatchContextBuilder` reading that mode's deck source; the mode's controller calls `IMatchingBridge.RegisterPendingBattle(vid, ctx)`. No changes to `SVSim.BattleNode`. |
|
|
||||||
| Add a real AI opponent | Replace the static dispatch in `BattleSession.ComputeResponses` (`TurnEnd → opponent TurnStart` case) with one that drives a decision engine. The `OutboundSequencer` already assigns `playSeq` for whatever you push. |
|
|
||||||
| Implement recovery | `IBattleSessionStore` already keeps the pending registry. Add a per-battle archive (the `OutboundSequencer.Archive` already retains every assigned-playSeq push) and bind it to the HTTP `/battle/get_recovery_params` endpoint. |
|
|
||||||
| Validate `battleCode` | Port `NetworkConsistency.GetConsistency` from the client decompilation. Hook into `BattleSession.HandleMsgEventAsync` on `TurnEnd` / `Judge`. |
|
|
||||||
| Type the `orderList` register actions | Spec at `docs/api-spec/in-battle/register-actions.md` catalogs the eight shapes observed in TK2 captures. Build a discriminated union; replace `Dictionary<string, object?>` in the `Body` for the relevant URIs. |
|
|
||||||
|
|
||||||
## Test layout
|
|
||||||
|
|
||||||
```
|
|
||||||
SVSim.UnitTests/BattleNode/
|
|
||||||
├─ Bridge/ MatchingBridgeTests (3 tests — mint id, dedup, format)
|
|
||||||
├─ Integration/ BattleNodeFlowTests (end-to-end via WebApplicationFactory)
|
|
||||||
│ RawSocketIoTestClient (test helper)
|
|
||||||
├─ Lifecycle/ ScriptedLifecycleTests (11 tests)
|
|
||||||
├─ Protocol/ MsgEnvelopeTests (4 tests incl. number-array regression)
|
|
||||||
│ MsgPayloadCodecTests (2 tests — roundtrip + known vector)
|
|
||||||
├─ Reliability/ GungnirTests / InboundTrackerTests / OutboundSequencerTests
|
|
||||||
├─ Sessions/ BattleSessionDispatchTests (8 tests — phase-state machine)
|
|
||||||
│ InMemoryBattleSessionStoreTests
|
|
||||||
└─ Wire/ NodeCryptoTests (with fixed-vector regression)
|
|
||||||
EngineIoFrameTests
|
|
||||||
SocketIoFrameTests (incl. binary attachment + JSON escaping)
|
|
||||||
```
|
|
||||||
|
|
||||||
Total ~71 BattleNode-scoped tests. The integration test boots the EmulatedEntrypoint host via `SVSimTestFactory`, mints a battle through `IMatchingBridge`, opens a TestServer WebSocket, and walks the full handshake through Ready. It exercises every layer.
|
|
||||||
|
|
||||||
## Related docs
|
|
||||||
|
|
||||||
- `docs/api-spec/in-battle/transport.md` — Socket.IO + AES-for-node wire format, with smoke corrections inline.
|
|
||||||
- `docs/api-spec/in-battle/matching.md` — `do_matching` bridge + client state machine.
|
|
||||||
- `docs/api-spec/in-battle/server-to-client.md`, `client-to-server.md` — per-uri frame shapes.
|
|
||||||
- `docs/api-spec/in-battle/register-actions.md` — `orderList` action catalog (for v2).
|
|
||||||
- `docs/api-spec/in-battle/reliability.md` — pubSeq/playSeq stocking + Gungnir.
|
|
||||||
- `docs/api-spec/in-battle/recovery.md` — the reconnect handshake (deferred to v2).
|
|
||||||
- `docs/operations/battle-node-smoke.md` — manual end-to-end checklist.
|
|
||||||
- `docs/operations/battle-node-smoke-walkthrough.md` — annotated walkthrough with per-step diagnostics.
|
|
||||||
- `docs/superpowers/specs/2026-05-31-battle-node-transport-design.md` — v1 design.
|
|
||||||
- `docs/superpowers/plans/2026-05-31-battle-node-transport.md` — v1 implementation plan.
|
|
||||||
|
|||||||
@@ -58,11 +58,13 @@ public sealed class BattleSession
|
|||||||
|
|
||||||
if (Phase != BattleSessionPhase.Terminal)
|
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
|
try
|
||||||
{
|
{
|
||||||
await survivor.PushAsync(
|
await survivor.PushAsync(
|
||||||
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
|
BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -220,12 +222,11 @@ public sealed class BattleSession
|
|||||||
break;
|
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.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
|
||||||
if (Type == BattleType.Pvp && BothAfterReady())
|
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 turnEndBroadcast = BuildTurnEndBroadcast();
|
||||||
var judgeBroadcast = BuildJudgeBroadcast();
|
var judgeBroadcast = BuildJudgeBroadcast();
|
||||||
result.Add((from, turnEndBroadcast, false));
|
result.Add((from, turnEndBroadcast, false));
|
||||||
@@ -235,24 +236,34 @@ public sealed class BattleSession
|
|||||||
}
|
}
|
||||||
else if (Type == BattleType.Scripted)
|
else if (Type == BattleType.Scripted)
|
||||||
{
|
{
|
||||||
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
|
|
||||||
result.Add((other, env, false));
|
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;
|
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.Retire:
|
||||||
case NetworkBattleUri.Kill:
|
case NetworkBattleUri.Kill:
|
||||||
if (Type == BattleType.Pvp)
|
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
|
||||||
{
|
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
|
||||||
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));
|
|
||||||
}
|
|
||||||
Phase = BattleSessionPhase.Terminal;
|
Phase = BattleSessionPhase.Terminal;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -274,14 +285,17 @@ public sealed class BattleSession
|
|||||||
result.Add((other, env, false));
|
result.Add((other, env, false));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// --- PvP gameplay forwarding (post-AfterReady).
|
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
|
||||||
// Order matters: this MUST come after the FakeOpponentViewerId arms so
|
// BothAfterReady() is only true when both participants are RealParticipants
|
||||||
// Scripted bot emissions don't fall into the PvP forwarder.
|
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
|
||||||
case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
|
// always null), so this arm naturally fires for PvP only. Order matters:
|
||||||
case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
|
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
|
||||||
case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
|
// emissions don't fall into this forwarder.
|
||||||
case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
|
case NetworkBattleUri.TurnStart when BothAfterReady():
|
||||||
case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && 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));
|
result.Add((other, env, false));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -322,17 +336,6 @@ public sealed class BattleSession
|
|||||||
PlaySeq: null,
|
PlaySeq: null,
|
||||||
Body: new ResultCodeOnlyBody());
|
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(
|
private MsgEnvelope BuildTurnEndBroadcast() => new(
|
||||||
NetworkBattleUri.TurnEnd,
|
NetworkBattleUri.TurnEnd,
|
||||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
{
|
{
|
||||||
private readonly WebSocket _ws;
|
private readonly WebSocket _ws;
|
||||||
private readonly ILogger<RealParticipant> _log;
|
private readonly ILogger<RealParticipant> _log;
|
||||||
|
private readonly bool _diagnosticLogging;
|
||||||
private CancellationToken _sessionCt;
|
private CancellationToken _sessionCt;
|
||||||
|
|
||||||
public long ViewerId { get; }
|
public long ViewerId { get; }
|
||||||
@@ -85,10 +86,11 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
}
|
}
|
||||||
|
|
||||||
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
|
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
|
||||||
ILogger<RealParticipant> log)
|
ILogger<RealParticipant> log, bool diagnosticLogging = false)
|
||||||
{
|
{
|
||||||
_ws = ws;
|
_ws = ws;
|
||||||
_log = log;
|
_log = log;
|
||||||
|
_diagnosticLogging = diagnosticLogging;
|
||||||
ViewerId = viewerId;
|
ViewerId = viewerId;
|
||||||
Context = context;
|
Context = context;
|
||||||
}
|
}
|
||||||
@@ -101,47 +103,78 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
var buffer = new byte[8192];
|
var buffer = new byte[8192];
|
||||||
var pendingAttachments = new List<byte[]>();
|
var pendingAttachments = new List<byte[]>();
|
||||||
SocketIoFrame? pendingFrame = null;
|
SocketIoFrame? pendingFrame = null;
|
||||||
|
string exitReason = "loop-condition-false";
|
||||||
|
|
||||||
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
|
try
|
||||||
{
|
{
|
||||||
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
|
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
|
||||||
if (msg is null) break;
|
|
||||||
|
|
||||||
if (msg.Value.IsText)
|
|
||||||
{
|
{
|
||||||
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
|
||||||
if (text.Length == 0) continue;
|
if (msg is null) { exitReason = "read-returned-null"; break; }
|
||||||
var eio = EngineIoFrame.Parse(text);
|
|
||||||
if (eio.Type == EngineIoPacketType.Ping)
|
|
||||||
{
|
|
||||||
await SendTextAsync("3", cancellation);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (eio.Type != EngineIoPacketType.Message) continue;
|
|
||||||
|
|
||||||
var sio = SocketIoFrame.Parse(eio.Payload);
|
if (msg.Value.IsText)
|
||||||
if (sio.AttachmentCount > 0)
|
|
||||||
{
|
{
|
||||||
pendingFrame = sio;
|
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
||||||
pendingAttachments.Clear();
|
if (text.Length == 0) continue;
|
||||||
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;
|
_log.LogWarning(
|
||||||
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
|
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
|
||||||
{
|
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +182,12 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
|
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);
|
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +219,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
|
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
|
||||||
await HandleAliveEventAsync(frame);
|
await HandleAliveEventAsync(frame);
|
||||||
return;
|
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);
|
_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 shouldDispatch = true;
|
||||||
|
bool ackSent = false;
|
||||||
|
long? ackArg = null;
|
||||||
if (env.PubSeq.HasValue)
|
if (env.PubSeq.HasValue)
|
||||||
{
|
{
|
||||||
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
|
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
|
||||||
if (frame.AckId.HasValue)
|
if (frame.AckId.HasValue)
|
||||||
{
|
{
|
||||||
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
|
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 (!shouldDispatch) return;
|
||||||
|
|
||||||
if (FrameEmitted is not null)
|
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)
|
private async Task HandleAliveEventAsync(SocketIoFrame frame)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -306,9 +446,26 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
|||||||
do
|
do
|
||||||
{
|
{
|
||||||
try { result = await _ws.ReceiveAsync(buffer, ct); }
|
try { result = await _ws.ReceiveAsync(buffer, ct); }
|
||||||
catch (OperationCanceledException) { return null; }
|
catch (OperationCanceledException)
|
||||||
catch (WebSocketException) { return null; }
|
{
|
||||||
if (result.MessageType == WebSocketMessageType.Close) return null;
|
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);
|
ms.Write(buffer, 0, result.Count);
|
||||||
} while (!result.EndOfMessage);
|
} while (!result.EndOfMessage);
|
||||||
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
|
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
|
|||||||
{
|
{
|
||||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
||||||
public MatchContext Context { get; } = new(
|
public MatchContext Context { get; } = new(
|
||||||
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 (matches the
|
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched)
|
||||||
// hardcoded OppoDeckCount that ScriptedProfiles.OpponentMatchedProfile shipped).
|
// shipped OppoDeckCount: 30.
|
||||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
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",
|
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",
|
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||||
BattleType: 0);
|
BattleType: 0);
|
||||||
@@ -37,9 +37,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
|
|||||||
|
|
||||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
|
// React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
|
||||||
// three-frame burst. Everything else is silently swallowed.
|
// Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
|
||||||
if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
|
// (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.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
|
||||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -11,23 +11,17 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
|||||||
public class ArenaTwoPickBattleController : SVSimController
|
public class ArenaTwoPickBattleController : SVSimController
|
||||||
{
|
{
|
||||||
private readonly IArenaTwoPickService _svc;
|
private readonly IArenaTwoPickService _svc;
|
||||||
private readonly IMatchingBridge _matching;
|
|
||||||
private readonly IMatchContextBuilder _matchContextBuilder;
|
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||||
private readonly IMatchingPairUpService _pairUp;
|
private readonly IMatchingResolver _resolver;
|
||||||
private readonly BattleNodeOptions _battleNodeOptions;
|
|
||||||
|
|
||||||
public ArenaTwoPickBattleController(
|
public ArenaTwoPickBattleController(
|
||||||
IArenaTwoPickService svc,
|
IArenaTwoPickService svc,
|
||||||
IMatchingBridge matching,
|
|
||||||
IMatchContextBuilder matchContextBuilder,
|
IMatchContextBuilder matchContextBuilder,
|
||||||
IMatchingPairUpService pairUp,
|
IMatchingResolver resolver)
|
||||||
BattleNodeOptions battleNodeOptions)
|
|
||||||
{
|
{
|
||||||
_svc = svc;
|
_svc = svc;
|
||||||
_matching = matching;
|
|
||||||
_matchContextBuilder = matchContextBuilder;
|
_matchContextBuilder = matchContextBuilder;
|
||||||
_pairUp = pairUp;
|
_resolver = resolver;
|
||||||
_battleNodeOptions = battleNodeOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("do_matching")]
|
[HttpPost("do_matching")]
|
||||||
@@ -37,59 +31,21 @@ public class ArenaTwoPickBattleController : SVSimController
|
|||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||||
// Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path.
|
// Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted
|
||||||
// ASP.NET's default bool binder rejects "1", so do a permissive parse here.
|
// path. ASP.NET's default bool binder rejects "1", so parse permissively here.
|
||||||
// The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other
|
// BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is
|
||||||
// route — it bypasses pair-up for every solo poll, useful when the live client
|
// applied inside the resolver.
|
||||||
// (which can't append query params) needs a Scripted match.
|
var scriptedOptIn = scripted is not null
|
||||||
var useScripted = (scripted is not null
|
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase));
|
||||||
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)))
|
|
||||||
|| _battleNodeOptions.SoloDefaultsToScripted;
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||||
|
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct);
|
||||||
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.
|
|
||||||
return Ok(new DoMatchingResponseDto
|
return Ok(new DoMatchingResponseDto
|
||||||
{
|
{
|
||||||
MatchingState = paired.IsOwner ? 3007 : 3004,
|
MatchingState = r.MatchingState,
|
||||||
BattleId = paired.Match.BattleId,
|
BattleId = r.BattleId,
|
||||||
NodeServerUrl = paired.Match.NodeServerUrl,
|
NodeServerUrl = r.NodeServerUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
catch (ArenaTwoPickException ex)
|
catch (ArenaTwoPickException ex)
|
||||||
|
|||||||
@@ -23,23 +23,20 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
|||||||
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
||||||
public sealed class RankBattleController : ControllerBase
|
public sealed class RankBattleController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMatchingPairUpService _pairUp;
|
private readonly IMatchingResolver _resolver;
|
||||||
private readonly IMatchingBridge _bridge;
|
|
||||||
private readonly IBattleSessionStore _sessionStore;
|
private readonly IBattleSessionStore _sessionStore;
|
||||||
private readonly IMatchContextBuilder _ctxBuilder;
|
private readonly IMatchContextBuilder _ctxBuilder;
|
||||||
private readonly IBotRoster _botRoster;
|
private readonly IBotRoster _botRoster;
|
||||||
private readonly ILogger<RankBattleController> _log;
|
private readonly ILogger<RankBattleController> _log;
|
||||||
|
|
||||||
public RankBattleController(
|
public RankBattleController(
|
||||||
IMatchingPairUpService pairUp,
|
IMatchingResolver resolver,
|
||||||
IMatchingBridge bridge,
|
|
||||||
IBattleSessionStore sessionStore,
|
IBattleSessionStore sessionStore,
|
||||||
IMatchContextBuilder ctxBuilder,
|
IMatchContextBuilder ctxBuilder,
|
||||||
IBotRoster botRoster,
|
IBotRoster botRoster,
|
||||||
ILogger<RankBattleController> log)
|
ILogger<RankBattleController> log)
|
||||||
{
|
{
|
||||||
_pairUp = pairUp;
|
_resolver = resolver;
|
||||||
_bridge = bridge;
|
|
||||||
_sessionStore = sessionStore;
|
_sessionStore = sessionStore;
|
||||||
_ctxBuilder = ctxBuilder;
|
_ctxBuilder = ctxBuilder;
|
||||||
_botRoster = botRoster;
|
_botRoster = botRoster;
|
||||||
@@ -135,33 +132,16 @@ public sealed class RankBattleController : ControllerBase
|
|||||||
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var paired = await _pairUp.TryPairAsync(mode, new BattlePlayer(vid, ctx), ct);
|
// 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
|
||||||
if (paired is null)
|
// 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);
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Ok(new DoMatchingResponseDto
|
return Ok(new DoMatchingResponseDto
|
||||||
{
|
{
|
||||||
MatchingState = state,
|
MatchingState = r.MatchingState,
|
||||||
BattleId = paired.Match.BattleId,
|
BattleId = r.BattleId,
|
||||||
NodeServerUrl = paired.Match.NodeServerUrl,
|
NodeServerUrl = r.NodeServerUrl,
|
||||||
// Placeholder per spec § Out of scope — per-battle card-master split is deferred.
|
// Placeholder per spec § Out of scope — per-battle card-master split is deferred.
|
||||||
CardMasterId = 0,
|
CardMasterId = 0,
|
||||||
});
|
});
|
||||||
|
|||||||
54
SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs
Normal file
54
SVSim.EmulatedEntrypoint/Matching/IMatchingResolver.cs
Normal 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);
|
||||||
63
SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs
Normal file
63
SVSim.EmulatedEntrypoint/Matching/MatchingResolver.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -138,6 +138,10 @@ public class Program
|
|||||||
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
|
new ModePolicy("unlimited_rank_battle", PolicyKind.PvpFirstThenAiFallback),
|
||||||
}));
|
}));
|
||||||
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
|
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.
|
// Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info.
|
||||||
// Transient because BotRoster depends on the transient IGlobalsRepository.
|
// Transient because BotRoster depends on the transient IGlobalsRepository.
|
||||||
builder.Services.AddTransient<IBotRoster, BotRoster>();
|
builder.Services.AddTransient<IBotRoster, BotRoster>();
|
||||||
@@ -156,7 +160,7 @@ public class Program
|
|||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Update database (skipped for non-relational providers, e.g. InMemory in tests, and
|
// 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
|
// 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).
|
// EnsureCreated against a SQLite in-memory DB — the Postgres migrations would fail there).
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"BattleNode": {
|
"BattleNode": {
|
||||||
"SoloDefaultsToScripted": false
|
"SoloDefaultsToScripted": false,
|
||||||
|
"DiagnosticLogging": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -281,9 +281,10 @@ public class BattleNodeFlowTests
|
|||||||
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
var aBody = (RawBody)aFinish.Body;
|
var aBody = (RawBody)aFinish.Body;
|
||||||
var bBody = (RawBody)bFinish.Body;
|
var bBody = (RawBody)bFinish.Body;
|
||||||
// BattleResult.Lose = 0, Win = 1.
|
// BattleResult.RetireLose = 106 (retirer), RetireWin = 105 (survivor). Player-
|
||||||
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Lose));
|
// perspective codes per the FinishBattleEffect trace.
|
||||||
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
|
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]
|
[Test]
|
||||||
@@ -319,11 +320,11 @@ public class BattleNodeFlowTests
|
|||||||
// Abruptly close A's WS (no Retire).
|
// Abruptly close A's WS (no Retire).
|
||||||
await clientA.DisposeAsync();
|
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);
|
var bFinish = await clientB.ReceiveSynchronizeAsync(ct);
|
||||||
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
var bBody = (RawBody)bFinish.Body;
|
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.
|
// PendingBattle should be evicted by the second arriver's RemovePending.
|
||||||
var store = factory.Services.GetRequiredService<SVSim.BattleNode.Sessions.IBattleSessionStore>();
|
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
|
// 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
|
// 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
|
// 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 parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, and
|
||||||
// handler's HTTP method returns, and the TestServer-side WS shuts down. ReceiveAsync
|
// the polite-close path emits an EIO "1" Close text frame followed by a clean
|
||||||
// observes the shutdown either by returning a Close message or throwing.
|
// WebSocket close handshake before the handler returns.
|
||||||
|
bool politeFrameObserved = false;
|
||||||
bool closeObserved = false;
|
bool closeObserved = false;
|
||||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||||
|
var buf = new byte[1024];
|
||||||
while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65))
|
while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65))
|
||||||
{
|
{
|
||||||
try
|
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)
|
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
|
||||||
{
|
{
|
||||||
closeObserved = true;
|
closeObserved = true;
|
||||||
break;
|
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
|
catch
|
||||||
{
|
{
|
||||||
@@ -391,6 +399,8 @@ public class BattleNodeFlowTests
|
|||||||
break;
|
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,
|
Assert.That(closeObserved, Is.True,
|
||||||
"A's WS should close (or ReceiveAsync should fail) after the waiting-room timeout.");
|
"A's WS should close (or ReceiveAsync should fail) after the waiting-room timeout.");
|
||||||
wsA.Dispose();
|
wsA.Dispose();
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
@@ -34,27 +34,17 @@ public class SmallBodiesTests
|
|||||||
[Test]
|
[Test]
|
||||||
public void BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues()
|
public void BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues()
|
||||||
{
|
{
|
||||||
// The wire field is the int code (Win=1); BattleResult uses JsonNumberEnumConverter
|
// The wire field is the int RESULT_CODE (LifeWin=101); BattleResult uses
|
||||||
// to override the default JsonStringEnumConverter (which would emit "Win" instead).
|
// JsonNumberEnumConverter to override the default JsonStringEnumConverter (which
|
||||||
var body = new BattleFinishBody(Result: BattleResult.Win);
|
// would emit "LifeWin" instead).
|
||||||
|
var body = new BattleFinishBody(Result: BattleResult.LifeWin);
|
||||||
|
|
||||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
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));
|
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]
|
[Test]
|
||||||
public void AlivePushBody_SerializesScsAndOcs_AndDoesNotIncludeResultCode()
|
public void AlivePushBody_SerializesScsAndOcs_AndDoesNotIncludeResultCode()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,6 +82,48 @@ public class BattleSessionDispatchTests
|
|||||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
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]
|
[Test]
|
||||||
public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase()
|
public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase()
|
||||||
{
|
{
|
||||||
@@ -154,28 +196,46 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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));
|
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].Target, Is.SameAs(a));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||||
Assert.That(routes[0].NoStock, Is.True);
|
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));
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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));
|
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].Target, Is.SameAs(a));
|
||||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
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));
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,17 +420,29 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
DriveToAfterReady(s, b);
|
DriveToAfterReady(s, b);
|
||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
||||||
|
|
||||||
Assert.That(routes.Count, Is.EqualTo(4));
|
Assert.That(routes.Count, Is.EqualTo(3));
|
||||||
Assert.That(routes.Select(r => r.Frame.Uri).Distinct(),
|
|
||||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
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]
|
[Test]
|
||||||
@@ -386,7 +458,7 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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();
|
var (s, a, b) = NewPvpSession();
|
||||||
DriveToAfterReady(s, a);
|
DriveToAfterReady(s, a);
|
||||||
@@ -398,9 +470,9 @@ public class BattleSessionDispatchTests
|
|||||||
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
|
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
|
||||||
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
|
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
|
||||||
Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
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(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(aRoute.NoStock, Is.True);
|
||||||
Assert.That(bRoute.NoStock, Is.True);
|
Assert.That(bRoute.NoStock, Is.True);
|
||||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||||
@@ -420,15 +492,20 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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.
|
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
|
||||||
var (s, a, _) = NewSession();
|
// 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));
|
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].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()
|
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||||
@@ -558,9 +635,11 @@ public class BattleSessionDispatchTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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.InitNetwork));
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||||
@@ -568,9 +647,11 @@ public class BattleSessionDispatchTests
|
|||||||
|
|
||||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
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].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()
|
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()
|
||||||
|
|||||||
@@ -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<object>, 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);
|
||||||
|
}
|
||||||
@@ -28,22 +28,20 @@ public class ScriptedBotParticipantTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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
|
// TurnEndFinal is the game-end signal — owned by BattleSession's TurnEndFinal
|
||||||
// bot's response shape is unchanged for v1.2 behaviour preservation.
|
// 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 p = new ScriptedBotParticipant();
|
||||||
var emitted = new List<NetworkBattleUri>();
|
var fired = 0;
|
||||||
p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; };
|
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
||||||
|
|
||||||
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
|
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
|
||||||
|
|
||||||
Assert.That(emitted, Is.EqualTo(new[]
|
Assert.That(fired, Is.EqualTo(0),
|
||||||
{
|
"TurnEndFinal must not trigger the bot's burst — the dispatch arm pushes BattleFinish directly.");
|
||||||
NetworkBattleUri.TurnStart,
|
|
||||||
NetworkBattleUri.TurnEnd,
|
|
||||||
NetworkBattleUri.Judge,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
|||||||
118
SVSim.UnitTests/Controllers/DoMatchingContractTests.cs
Normal file
118
SVSim.UnitTests/Controllers/DoMatchingContractTests.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
129
SVSim.UnitTests/Matching/MatchingResolverTests.cs
Normal file
129
SVSim.UnitTests/Matching/MatchingResolverTests.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user