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>
|
||||
/// </summary>
|
||||
public bool SoloDefaultsToScripted { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
|
||||
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
|
||||
/// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound
|
||||
/// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, noStock);
|
||||
/// <c>[ws-rx-text]</c> / <c>[ws-rx-bin]</c> on every WS frame received at the transport
|
||||
/// layer; <c>[ws-recv-exit]</c> / <c>[ws-loop-exit]</c> on read-loop termination
|
||||
/// (with WebSocket state + exception type when applicable). Default false — keeps
|
||||
/// production logs clean. Flip on per session for live WS debugging, PvP investigation,
|
||||
/// or to reproduce the kind of softlock chased in
|
||||
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
||||
/// </summary>
|
||||
public bool DiagnosticLogging { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
@@ -122,7 +124,7 @@ public sealed class BattleNodeWebSocketHandler
|
||||
{
|
||||
_store.RemovePending(battleId);
|
||||
var realParticipant = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||
var scriptedBot = new ScriptedBotParticipant();
|
||||
var session = new BattleSession(battleId, pending.Type, realParticipant, scriptedBot,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
@@ -135,7 +137,7 @@ public sealed class BattleNodeWebSocketHandler
|
||||
// Pick this connection's MatchContext (P1's if isP1, P2's if isP2).
|
||||
var selfCtx = isP1 ? pending.P1.Context : pending.P2!.Context;
|
||||
var self = new RealParticipant(ws, viewerId, selfCtx,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||
|
||||
var firstArriver = _waitingRoom.Pair(battleId, self);
|
||||
|
||||
@@ -172,6 +174,7 @@ public sealed class BattleNodeWebSocketHandler
|
||||
"PvP waiting-room timeout or race on BattleId={Bid}; first arriver disconnected.",
|
||||
battleId);
|
||||
_store.RemovePending(battleId);
|
||||
await TryPoliteCloseAsync(ws, "waiting-room timeout", battleId);
|
||||
return;
|
||||
}
|
||||
// Retry succeeded — we're the de-facto second arriver now. Own the session.
|
||||
@@ -199,7 +202,7 @@ public sealed class BattleNodeWebSocketHandler
|
||||
// earlier isP1/isP2 check has already rejected viewer mismatches.
|
||||
_store.RemovePending(battleId);
|
||||
var botReal = new RealParticipant(ws, viewerId, pending.P1.Context,
|
||||
_loggerFactory.CreateLogger<RealParticipant>());
|
||||
_loggerFactory.CreateLogger<RealParticipant>(), _options.DiagnosticLogging);
|
||||
var noopBot = new NoOpBotParticipant();
|
||||
var botSession = new BattleSession(battleId, BattleType.Bot, botReal, noopBot,
|
||||
_loggerFactory.CreateLogger<BattleSession>());
|
||||
@@ -209,6 +212,7 @@ public sealed class BattleNodeWebSocketHandler
|
||||
|
||||
default:
|
||||
_log.LogError("Unknown BattleType={Type} for BattleId={Bid}; closing WS", pending.Type, battleId);
|
||||
await TryPoliteCloseAsync(ws, $"unknown BattleType={pending.Type}", battleId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -219,4 +223,37 @@ public sealed class BattleNodeWebSocketHandler
|
||||
if (!string.IsNullOrEmpty(header)) return header;
|
||||
return ctx.Request.Query[name].ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit an EIO <c>1</c> (Close) text frame, then run the WebSocket close handshake with
|
||||
/// <see cref="WebSocketCloseStatus.NormalClosure"/>. Without the EIO frame, BestHTTP /
|
||||
/// socket.io-client log the disconnect as an abrupt drop rather than a controlled
|
||||
/// disconnect; without the close handshake, the client only sees the TCP teardown after
|
||||
/// Kestrel finishes draining. Best-effort: any exception (already-torn-down socket,
|
||||
/// canceled token) is swallowed at Debug level since teardown races are routine.
|
||||
/// </summary>
|
||||
private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId)
|
||||
{
|
||||
// Use a fresh, short timeout — ctx.RequestAborted may already be canceled by the
|
||||
// path that decided to bail out, which would skip the close immediately.
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
if (ws.State == WebSocketState.Open)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(((int)EngineIoPacketType.Close).ToString());
|
||||
await ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cts.Token);
|
||||
}
|
||||
if (ws.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, reason, cts.Token);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogDebug(ex,
|
||||
"polite close failed on BattleId={Bid} (reason={Reason}); socket likely already torn down.",
|
||||
battleId, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Lifecycle;
|
||||
|
||||
/// <summary>
|
||||
@@ -15,32 +13,11 @@ internal static class ScriptedProfiles
|
||||
// From frame[2] (Matched).
|
||||
public const long BattleSeed = 17_548_138L;
|
||||
|
||||
public static readonly MatchedOppoInfo OpponentMatchedProfile = new(
|
||||
CountryCode: "JPN",
|
||||
UserName: "Opponent",
|
||||
SleeveId: "704141010",
|
||||
EmblemId: "400001100",
|
||||
DegreeId: "120027",
|
||||
FieldId: 5,
|
||||
IsOfficial: 0,
|
||||
OppoId: 0,
|
||||
Seed: BattleSeed,
|
||||
OppoDeckCount: 30);
|
||||
|
||||
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
|
||||
// from real per-viewer state needs a TK2 rank/battle-point tracker.
|
||||
public const string PlayerRank = "10";
|
||||
public const string PlayerBattlePoint = "6270";
|
||||
|
||||
public static readonly BattleStartOppoInfo OpponentBattleStartProfile = new(
|
||||
Rank: "1",
|
||||
IsMasterRank: "0",
|
||||
BattlePoint: 0,
|
||||
MasterPoint: "0",
|
||||
ClassId: "8",
|
||||
CharaId: "8",
|
||||
CardMasterName: "card_master_node_10015");
|
||||
|
||||
// From frame[8] (Ready). Provenance is "what prod sent"; the client
|
||||
// doesn't validate, but echoing matches the capture protects against
|
||||
// a regression on a future tightening.
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
namespace SVSim.BattleNode.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Wire value of <c>result</c> on a BattleFinish frame. The client's
|
||||
/// <c>BattleFinishResponsProcessing</c> switch maps these as:
|
||||
/// 0 → LOSE, 1 → WIN, 2 → CONSISTENCY (desync / action-list mismatch).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is NOT the same as the client's in-memory <c>BATTLE_RESULT_TYPE</c> enum
|
||||
/// (NONE=0, WIN=1, LOSE=2, CONSISTENCY=3) — the wire codes shift LOSE down to 0.
|
||||
/// Wire value of <c>result</c> on a WS <c>BattleFinish</c> frame.
|
||||
/// <para>
|
||||
/// Maps to the client's <c>NetworkBattleReceiver.RESULT_CODE</c> enum at
|
||||
/// <c>NetworkBattleReceiver.cs:963-986</c>. Names are <b>from the player's perspective</b>:
|
||||
/// <c>LifeWin</c> = "I won by life", <c>LifeLose</c> = "I lost by life". Verified
|
||||
/// end-to-end via the path
|
||||
/// <c>RESULT_CODE</c> → <c>JudgeResultReceive</c> switch → <c>_finishEffectType</c> →
|
||||
/// <c>FinishBattleEffect</c> → <c>InitiateGameEndSequence(hasWon)</c>:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>LifeWin = 101</c> → <c>_finishEffectType = LOSE</c> →
|
||||
/// <c>InitiateGameEndSequence(hasWon: true)</c> → PLAYER WIN UI</item>
|
||||
/// <item><c>LifeLose = 102</c> → <c>_finishEffectType = WIN</c> →
|
||||
/// <c>InitiateGameEndSequence(hasWon: false)</c> → PLAYER LOSE UI</item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// The <c>SettingResultUI_SpecialResultTypeText</c> switch passes the OPPONENT's
|
||||
/// outcome to set the secondary "by retire/by disconnect/etc." text — that's why
|
||||
/// the inner switch direction looks inverted (LifeWin → LOSE param, LifeLose → WIN
|
||||
/// param). The actual WIN/LOSE rendering happens in <c>FinishBattleEffect</c> via
|
||||
/// the <c>!isPlayer</c> flip at line 1315.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Prior docstrings on this enum had the direction backwards (claimed LifeLose
|
||||
/// → WIN UI from a misread of the inner switch); see
|
||||
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c> Addendum for the live
|
||||
/// reproduction that exposed the inversion.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Always serialize as the int value, not the name; see the
|
||||
/// <c>JsonNumberEnumConverter</c> on <see cref="Bodies.BattleFinishBody.Result"/>.
|
||||
/// </remarks>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public enum BattleResult
|
||||
{
|
||||
Lose = 0,
|
||||
Win = 1,
|
||||
Consistency = 2,
|
||||
/// <summary>Player won by reducing opponent's life to 0. Pushed to the winner
|
||||
/// on <c>TurnEndFinal</c>. Routes through the client switch to
|
||||
/// <c>InitiateGameEndSequence(hasWon: true)</c>.</summary>
|
||||
LifeWin = 101,
|
||||
|
||||
/// <summary>Player lost by their own life dropping to 0. Pushed to the loser on
|
||||
/// the opponent's <c>TurnEndFinal</c>. Prod TK2 capture at
|
||||
/// <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson:274</c> is a loss
|
||||
/// shown to the player from a real opponent's lethal.</summary>
|
||||
LifeLose = 102,
|
||||
|
||||
/// <summary>Player won because opponent retired. Pushed to the survivor on
|
||||
/// <c>Retire</c>/<c>Kill</c>. Same player-perspective convention as the Life codes.</summary>
|
||||
RetireWin = 105,
|
||||
|
||||
/// <summary>Player lost by retiring. Pushed to the retirer on <c>Retire</c>/<c>Kill</c>.</summary>
|
||||
RetireLose = 106,
|
||||
|
||||
/// <summary>Survivor wins because the opponent's socket dropped without a graceful
|
||||
/// <c>Retire</c>. Pushed to the survivor by the <see cref="Sessions.BattleSession"/>
|
||||
/// RunAsync drop cascade. Client <c>RESULT_CODE.DisconnectWin</c> renders the
|
||||
/// "opponent disconnected" result text → player WIN UI. Same player-perspective
|
||||
/// convention as the Life/Retire codes.</summary>
|
||||
DisconnectWin = 201,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,15 @@ internal static class WireConstants
|
||||
/// <summary>SIO event name for Gungnir keepalive frames (both directions).</summary>
|
||||
public const string AliveEvent = "alive";
|
||||
|
||||
/// <summary>
|
||||
/// SIO event name for client-emitted hand frames (touches + skill/object selection).
|
||||
/// Stocked variants (<c>SELECT_SKILL_URI</c>, <c>SLIDE_OBJECT_URI</c>) carry an ack-id;
|
||||
/// fire-and-forget variants (<c>TOUCH_URI</c>, <c>SELECT_OBJECT_URI</c>,
|
||||
/// <c>TURN_END_READY_URI</c>) do not. The body wire shape differs from <c>msg</c>
|
||||
/// frames — see <c>HandleHandEventAsync</c>.
|
||||
/// </summary>
|
||||
public const string HandEvent = "hand";
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder UUID we stamp on every server-originated envelope. Prod servers stamp a
|
||||
/// real per-request UUID; the client doesn't validate it.
|
||||
|
||||
@@ -1,142 +1,21 @@
|
||||
# SVSim.BattleNode
|
||||
|
||||
Socket.IO node-server scaffolding for in-battle traffic. Implements the second of the prod 4-server topology — the realtime channel that handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` / `TurnEnd` between the client and a server-side opponent.
|
||||
Socket.IO node-server emulation for in-battle real-time traffic — the second of prod's 4-server
|
||||
topology. Handles `Matched` / `BattleStart` / `Deal` / per-action `PlayActions` / `Echo` /
|
||||
`TurnEnd` between a client and a server-side opponent, for TK2 PvP and AI rank battles.
|
||||
|
||||
**v1 scope** is "scripted thin sequencer": the server accepts a connection, walks a hand-rolled lifecycle from `InitNetwork` to mulligan + first turn + opponent TurnStart, then sits at the opponent's-turn screen indefinitely. No real opponent, no `battleCode` validation, no recovery. v2 work targets each of those.
|
||||
## Documentation lives in the outer repo
|
||||
|
||||
The library has **no dependency on `SVSim.EmulatedEntrypoint`**. It exposes one DI seam (`IMatchingBridge`) and one ASP.NET Core integration surface (`AddBattleNode` / `UseBattleNode`). Pulling the node into a separate process later is one interface and one Kestrel binding.
|
||||
This project's canonical reference is a single hub doc in the **outer** SVSim repo (this directory
|
||||
is an inner git repo, so the doc isn't tracked alongside the code):
|
||||
|
||||
## Architecture
|
||||
→ **`docs/battle-node.md`** (from the SVSim root) — architecture, the dispatch matrix by battle
|
||||
type, connect handshake + crypto, `BattleFinish` wire-result semantics, SIO/EIO event coverage,
|
||||
reliability (pubSeq/playSeq/Gungnir), wire-format gotchas, where-to-extend, the manual smoke
|
||||
walkthrough, and the consolidated open-items list.
|
||||
|
||||
```
|
||||
SVSim.BattleNode/
|
||||
├─ Bridge/ IMatchingBridge — what /do_matching calls to mint a battle id + node URL
|
||||
├─ Hosting/ ASP.NET Core extensions + the /socket.io/ endpoint handler
|
||||
├─ Lifecycle/ ScriptedLifecycle — the v1 hand-rolled Matched/BattleStart/Deal/Swap/Ready frames
|
||||
├─ Protocol/ MsgEnvelope, NetworkBattleUri enum, msgpack ↔ envelope codec
|
||||
├─ Reliability/ InboundTracker (pubSeq dedup), OutboundSequencer (playSeq archive), Gungnir
|
||||
├─ Sessions/ BattleSession (per-connection state + WS pump), IBattleSessionStore
|
||||
└─ Wire/ EIO3 framing, SIO2 framing, NodeCrypto (AES-256-CBC)
|
||||
```
|
||||
Relative path from here: [`../../../docs/battle-node.md`](../../../docs/battle-node.md).
|
||||
|
||||
## Connect handshake (verified end-to-end against the real client)
|
||||
Detailed per-URI wire shapes are in `docs/api-spec/in-battle/`; the hub links into them.
|
||||
|
||||
```
|
||||
┌────────┐ ┌────────────┐
|
||||
│ Client │ │ BattleNode │
|
||||
└────┬───┘ └──────┬─────┘
|
||||
│ │
|
||||
│ HTTP POST /arena_two_pick_battle/do_matching │ (HTTP host)
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── { matching_state:3004, battle_id, node_server_url, │
|
||||
│ card_master_id, ... } │
|
||||
│ │
|
||||
│ WS upgrade ws://<node>/socket.io/ │
|
||||
│ headers: BattleId, viewerId=encryptForNode(uid) │
|
||||
├──────────────────────────────────────────────────────────────►│ AcceptWebSocketAsync
|
||||
│ ◄── EIO3 Open 0{sid,upgrades:[],pingInterval,pingTimeout} │
|
||||
│ │
|
||||
│ msg: InitNetwork (cat=99/general) │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: InitNetwork{resultCode:1} │
|
||||
│ │
|
||||
│ MatchingInitBattle: status=Connect; subscribe receiver │
|
||||
│ msg: InitBattle (cat=2/matching) │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: Matched{selfInfo,oppoInfo,selfDeck,bid} │
|
||||
│ │
|
||||
│ client loads decks/scene │
|
||||
│ msg: Loaded │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: BattleStart{turnState,battleType,...} │
|
||||
│ ◄── synchronize: Deal{self,oppo} │
|
||||
│ │
|
||||
│ mulligan UI; player chooses cards to swap │
|
||||
│ msg: Swap{idxList:[...]} │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: Swap{self:[post-mulligan hand]} │
|
||||
│ ◄── synchronize: Ready{self,oppo,idxChangeSeed,spin} │
|
||||
│ │
|
||||
│ turn 1: TurnStart, PlayActions, ..., TurnEnd │
|
||||
├──────────────────────────────────────────────────────────────►│
|
||||
│ ◄── synchronize: TurnStart{spin} (opponent turn signal) │
|
||||
│ │
|
||||
│ sits at "Opponent's turn…" — v1 stopping point │
|
||||
```
|
||||
|
||||
Each push from us carries a contiguous `playSeq`; client-emit `pubSeq` is echoed back via the Socket.IO ack callback. `Gungnir` runs a 5s alive heartbeat in parallel reporting `scs:ONLINE,ocs:ONLINE`.
|
||||
|
||||
## Wire-format gotchas (discovered during v1 smoke)
|
||||
|
||||
These are not in the original protocol docs and tripped us during the smoke walkthrough — leaving them here so the next reader doesn't repeat the diagnosis.
|
||||
|
||||
| Spec said | Actual wire | Where it shows up |
|
||||
|---|---|---|
|
||||
| `AdditionalQueryParams` on the WS upgrade | **HTTP request headers**, not query string. BestHTTP misnames the API. | `BattleNodeWebSocketHandler.ReadCredential` reads `BattleId` / `viewerId` from headers first, query as fallback (for tests). |
|
||||
| `node_server_url` ws://host:port | `host:port/socket.io/` — **no scheme prefix**, **path included**. | `BattleNodeOptions.NodeServerUrl` default + `do_matching` response. |
|
||||
| `card_master_id` optional | **Required** when `matching_state ∈ {3004,3007,3011}` — no `Keys.Contains` guard client-side. | Added to `DoMatchingResponseDto` with default `1`. |
|
||||
| `resultCode` optional on pushes | **Required = 1** on every scripted synchronize frame; missing means "drop in error handler". | `ScriptedLifecycle.EnvelopeForPush` injects it. |
|
||||
| Matched in response to InitNetwork | **InitBattle**. Matched in response to InitNetwork lands before the client's matching handler is subscribed and silently drops. | See dispatch in `BattleSession.ComputeResponses`. |
|
||||
| WS binary frames carry raw msgpack | EIO3 prefixes binary frames with `0x04` (Message type byte), same as the leading digit on text frames. | `BattleSession.RunAsync` strips on read; `EncodeAndSendAsync` prepends on send. |
|
||||
|
||||
There's also a JSON parsing pitfall worth knowing about (and that broke the mulligan): the inline conditional `el.TryGetInt64(out var l) ? l : el.GetDouble()` unifies its branches to the common implicit-convertible type. Since `long → double` is implicit, the long silently widens to double, and `OfType<long>` downstream drops every entry. See `MsgEnvelope.ParseNumber` for the fix — keep number parsing in a separate method so each branch boxes its own runtime type.
|
||||
|
||||
## v1 scripted opponent — what the client sees
|
||||
|
||||
The player half of `Matched` / `BattleStart` reads from a `MatchContext` assembled in
|
||||
`SVSim.EmulatedEntrypoint/Services/MatchContextBuilder` from the viewer's TK2 run + equipped
|
||||
cosmetics + config — so the mulligan renders the real drafted deck, drafted class/leader,
|
||||
and equipped emblem/degree. The opponent half stays scripted in `ScriptedProfiles`:
|
||||
|
||||
- **Opponent** is a fixed silhouette: `classId="8"`, JPN sleeve/emblem/degree, viewer id `999999999`.
|
||||
- **Battle seed** is `17548138L` in both info blocks (the seed is *shared* per battle per the spec).
|
||||
- **Mulligan** does real card replacement: any idx in your `idxList` is swapped for the next unused deck idx (`1..3` dealt, so `4..30` are pool).
|
||||
- **Opponent's turn** never actually does anything — we push a single `TurnStart{spin:100}` after your `TurnEnd` so the UI transitions to the opponent-turn display, then sit.
|
||||
|
||||
A few player-side fields are still hardcoded pending a follow-up slice — `Rank`, `BattlePoint`,
|
||||
`cardMasterName`, `fieldId`, and the per-battle RNG seed. See the spec's §Deferred plumbing
|
||||
table at `docs/superpowers/specs/2026-06-01-battle-node-real-drafted-deck-design.md` for
|
||||
what each needs.
|
||||
|
||||
## Where to extend
|
||||
|
||||
| You want to | Touch |
|
||||
|---|---|
|
||||
| Wire a new mode's `do_matching` (rank, free, open-room, …) | Add one `BuildFor<Mode>Async(viewerId, …)` method to `IMatchContextBuilder` reading that mode's deck source; the mode's controller calls `IMatchingBridge.RegisterPendingBattle(vid, ctx)`. No changes to `SVSim.BattleNode`. |
|
||||
| Add a real AI opponent | Replace the static dispatch in `BattleSession.ComputeResponses` (`TurnEnd → opponent TurnStart` case) with one that drives a decision engine. The `OutboundSequencer` already assigns `playSeq` for whatever you push. |
|
||||
| Implement recovery | `IBattleSessionStore` already keeps the pending registry. Add a per-battle archive (the `OutboundSequencer.Archive` already retains every assigned-playSeq push) and bind it to the HTTP `/battle/get_recovery_params` endpoint. |
|
||||
| Validate `battleCode` | Port `NetworkConsistency.GetConsistency` from the client decompilation. Hook into `BattleSession.HandleMsgEventAsync` on `TurnEnd` / `Judge`. |
|
||||
| Type the `orderList` register actions | Spec at `docs/api-spec/in-battle/register-actions.md` catalogs the eight shapes observed in TK2 captures. Build a discriminated union; replace `Dictionary<string, object?>` in the `Body` for the relevant URIs. |
|
||||
|
||||
## Test layout
|
||||
|
||||
```
|
||||
SVSim.UnitTests/BattleNode/
|
||||
├─ Bridge/ MatchingBridgeTests (3 tests — mint id, dedup, format)
|
||||
├─ Integration/ BattleNodeFlowTests (end-to-end via WebApplicationFactory)
|
||||
│ RawSocketIoTestClient (test helper)
|
||||
├─ Lifecycle/ ScriptedLifecycleTests (11 tests)
|
||||
├─ Protocol/ MsgEnvelopeTests (4 tests incl. number-array regression)
|
||||
│ MsgPayloadCodecTests (2 tests — roundtrip + known vector)
|
||||
├─ Reliability/ GungnirTests / InboundTrackerTests / OutboundSequencerTests
|
||||
├─ Sessions/ BattleSessionDispatchTests (8 tests — phase-state machine)
|
||||
│ InMemoryBattleSessionStoreTests
|
||||
└─ Wire/ NodeCryptoTests (with fixed-vector regression)
|
||||
EngineIoFrameTests
|
||||
SocketIoFrameTests (incl. binary attachment + JSON escaping)
|
||||
```
|
||||
|
||||
Total ~71 BattleNode-scoped tests. The integration test boots the EmulatedEntrypoint host via `SVSimTestFactory`, mints a battle through `IMatchingBridge`, opens a TestServer WebSocket, and walks the full handshake through Ready. It exercises every layer.
|
||||
|
||||
## Related docs
|
||||
|
||||
- `docs/api-spec/in-battle/transport.md` — Socket.IO + AES-for-node wire format, with smoke corrections inline.
|
||||
- `docs/api-spec/in-battle/matching.md` — `do_matching` bridge + client state machine.
|
||||
- `docs/api-spec/in-battle/server-to-client.md`, `client-to-server.md` — per-uri frame shapes.
|
||||
- `docs/api-spec/in-battle/register-actions.md` — `orderList` action catalog (for v2).
|
||||
- `docs/api-spec/in-battle/reliability.md` — pubSeq/playSeq stocking + Gungnir.
|
||||
- `docs/api-spec/in-battle/recovery.md` — the reconnect handshake (deferred to v2).
|
||||
- `docs/operations/battle-node-smoke.md` — manual end-to-end checklist.
|
||||
- `docs/operations/battle-node-smoke-walkthrough.md` — annotated walkthrough with per-step diagnostics.
|
||||
- `docs/superpowers/specs/2026-05-31-battle-node-transport-design.md` — v1 design.
|
||||
- `docs/superpowers/plans/2026-05-31-battle-node-transport.md` — v1 implementation plan.
|
||||
Keep `docs/battle-node.md` updated in the same change whenever you alter node behavior.
|
||||
|
||||
@@ -58,11 +58,13 @@ public sealed class BattleSession
|
||||
|
||||
if (Phase != BattleSessionPhase.Terminal)
|
||||
{
|
||||
// Involuntary drop (no graceful Retire): synthesize BattleFinish(Win) to survivor.
|
||||
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
|
||||
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
|
||||
// WIN UI; the legacy Win=1 used here previously rendered "no contest".
|
||||
try
|
||||
{
|
||||
await survivor.PushAsync(
|
||||
BuildBattleFinish(BattleResult.Win), noStock: true, cancellation)
|
||||
BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -220,12 +222,11 @@ public sealed class BattleSession
|
||||
break;
|
||||
}
|
||||
|
||||
// Regular TurnEnd: continues the game. Scripted forwards to bot for the 3-frame
|
||||
// burst; PvP broadcasts; Bot stays silent.
|
||||
case NetworkBattleUri.TurnEnd when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
if (Type == BattleType.Pvp && BothAfterReady())
|
||||
{
|
||||
// Broadcast TurnEnd + Judge to BOTH. Each client's JudgeOperation ->
|
||||
// ControlTurnStartPlayer advances the active-player state machine.
|
||||
var turnEndBroadcast = BuildTurnEndBroadcast();
|
||||
var judgeBroadcast = BuildJudgeBroadcast();
|
||||
result.Add((from, turnEndBroadcast, false));
|
||||
@@ -235,24 +236,34 @@ public sealed class BattleSession
|
||||
}
|
||||
else if (Type == BattleType.Scripted)
|
||||
{
|
||||
// Phase 1 Scripted: forward to bot; bot fires three-frame burst back.
|
||||
result.Add((other, env, false));
|
||||
}
|
||||
// For Bot type, no-op (NoOpBot swallows; client handles its own turn end).
|
||||
// Bot type: no-op (NoOpBot swallows; client handles its own turn end).
|
||||
break;
|
||||
|
||||
// TurnEndFinal: client signals the player's FINAL turn is over (game-end
|
||||
// condition met, usually killed opponent's leader). Unified across types:
|
||||
// forward the envelope to other (matches prod TK2 capture
|
||||
// battle-traffic_tk2_regular.ndjson:273 — loser-side receives TurnEndFinal
|
||||
// from server before BattleFinish), then push BattleFinish per-side with
|
||||
// player-perspective codes (LifeWin to winner, LifeLose to loser).
|
||||
// ScriptedBotParticipant no longer reacts to TurnEndFinal (only TurnEnd) —
|
||||
// this dispatch arm owns it. NoOpBotParticipant swallows. Phase → Terminal
|
||||
// so the RunAsync cascade doesn't synthesize a follow-up BattleFinish.
|
||||
case NetworkBattleUri.TurnEndFinal when phaseFrom?.Phase == BattleSessionPhase.AfterReady:
|
||||
result.Add((other, env, false));
|
||||
result.Add((from, BuildBattleFinish(BattleResult.LifeWin), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.LifeLose), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
// Retire / Kill: sender concedes (Retire) or the client requested an immediate
|
||||
// terminate (Kill). Unified across types: push BattleFinish per-side with the
|
||||
// proper retire codes. Bots swallow their push (no real-opponent state).
|
||||
case NetworkBattleUri.Retire:
|
||||
case NetworkBattleUri.Kill:
|
||||
if (Type == BattleType.Pvp)
|
||||
{
|
||||
result.Add((from, BuildBattleFinish(BattleResult.Lose), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.Win), true));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scripted (and future Bot) — sender wins by default (no real opponent).
|
||||
result.Add((from, BuildBattleFinishNoContest(), true));
|
||||
}
|
||||
result.Add((from, BuildBattleFinish(BattleResult.RetireLose), true));
|
||||
result.Add((other, BuildBattleFinish(BattleResult.RetireWin), true));
|
||||
Phase = BattleSessionPhase.Terminal;
|
||||
break;
|
||||
|
||||
@@ -274,14 +285,17 @@ public sealed class BattleSession
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
// --- PvP gameplay forwarding (post-AfterReady).
|
||||
// Order matters: this MUST come after the FakeOpponentViewerId arms so
|
||||
// Scripted bot emissions don't fall into the PvP forwarder.
|
||||
case NetworkBattleUri.TurnStart when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.PlayActions when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.Echo when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.TurnEndActions when Type == BattleType.Pvp && BothAfterReady():
|
||||
case NetworkBattleUri.JudgeResult when Type == BattleType.Pvp && BothAfterReady():
|
||||
// Gameplay-frame forwarding (post-AfterReady). Unified across types:
|
||||
// BothAfterReady() is only true when both participants are RealParticipants
|
||||
// (ScriptedBot/NoOpBot don't implement IHasHandshakePhase so their Phase is
|
||||
// always null), so this arm naturally fires for PvP only. Order matters:
|
||||
// this MUST come after the FakeOpponentViewerId arms so Scripted bot
|
||||
// emissions don't fall into this forwarder.
|
||||
case NetworkBattleUri.TurnStart when BothAfterReady():
|
||||
case NetworkBattleUri.PlayActions when BothAfterReady():
|
||||
case NetworkBattleUri.Echo when BothAfterReady():
|
||||
case NetworkBattleUri.TurnEndActions when BothAfterReady():
|
||||
case NetworkBattleUri.JudgeResult when BothAfterReady():
|
||||
result.Add((other, env, false));
|
||||
break;
|
||||
|
||||
@@ -322,17 +336,6 @@ public sealed class BattleSession
|
||||
PlaySeq: null,
|
||||
Body: new ResultCodeOnlyBody());
|
||||
|
||||
private MsgEnvelope BuildBattleFinishNoContest() => new(
|
||||
NetworkBattleUri.BattleFinish,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
Uuid: WireConstants.ServerUuid,
|
||||
Bid: null,
|
||||
Try: 0,
|
||||
Cat: EmitCategory.Battle,
|
||||
PubSeq: null,
|
||||
PlaySeq: null,
|
||||
Body: new BattleFinishBody(Result: BattleResult.Win));
|
||||
|
||||
private MsgEnvelope BuildTurnEndBroadcast() => new(
|
||||
NetworkBattleUri.TurnEnd,
|
||||
ViewerId: ScriptedLifecycle.FakeOpponentViewerId,
|
||||
|
||||
@@ -33,6 +33,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
{
|
||||
private readonly WebSocket _ws;
|
||||
private readonly ILogger<RealParticipant> _log;
|
||||
private readonly bool _diagnosticLogging;
|
||||
private CancellationToken _sessionCt;
|
||||
|
||||
public long ViewerId { get; }
|
||||
@@ -85,10 +86,11 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
}
|
||||
|
||||
public RealParticipant(WebSocket ws, long viewerId, MatchContext context,
|
||||
ILogger<RealParticipant> log)
|
||||
ILogger<RealParticipant> log, bool diagnosticLogging = false)
|
||||
{
|
||||
_ws = ws;
|
||||
_log = log;
|
||||
_diagnosticLogging = diagnosticLogging;
|
||||
ViewerId = viewerId;
|
||||
Context = context;
|
||||
}
|
||||
@@ -101,47 +103,78 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
var buffer = new byte[8192];
|
||||
var pendingAttachments = new List<byte[]>();
|
||||
SocketIoFrame? pendingFrame = null;
|
||||
string exitReason = "loop-condition-false";
|
||||
|
||||
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
|
||||
try
|
||||
{
|
||||
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
|
||||
if (msg is null) break;
|
||||
|
||||
if (msg.Value.IsText)
|
||||
while (_ws.State == WebSocketState.Open && !cancellation.IsCancellationRequested)
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
||||
if (text.Length == 0) continue;
|
||||
var eio = EngineIoFrame.Parse(text);
|
||||
if (eio.Type == EngineIoPacketType.Ping)
|
||||
{
|
||||
await SendTextAsync("3", cancellation);
|
||||
continue;
|
||||
}
|
||||
if (eio.Type != EngineIoPacketType.Message) continue;
|
||||
var msg = await ReadCompleteMessageAsync(buffer, cancellation);
|
||||
if (msg is null) { exitReason = "read-returned-null"; break; }
|
||||
|
||||
var sio = SocketIoFrame.Parse(eio.Payload);
|
||||
if (sio.AttachmentCount > 0)
|
||||
if (msg.Value.IsText)
|
||||
{
|
||||
pendingFrame = sio;
|
||||
pendingAttachments.Clear();
|
||||
continue;
|
||||
var text = Encoding.UTF8.GetString(msg.Value.Bytes);
|
||||
if (text.Length == 0) continue;
|
||||
var eio = EngineIoFrame.Parse(text);
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"[ws-rx-text] viewer={Vid} eioType={Eio} len={Len} preview={Preview}",
|
||||
ViewerId, eio.Type, text.Length,
|
||||
text.Length > 60 ? text.Substring(0, 60) + "..." : text);
|
||||
}
|
||||
if (eio.Type == EngineIoPacketType.Ping)
|
||||
{
|
||||
await SendTextAsync("3", cancellation);
|
||||
continue;
|
||||
}
|
||||
if (eio.Type != EngineIoPacketType.Message) continue;
|
||||
|
||||
var sio = SocketIoFrame.Parse(eio.Payload);
|
||||
if (sio.AttachmentCount > 0)
|
||||
{
|
||||
pendingFrame = sio;
|
||||
pendingAttachments.Clear();
|
||||
continue;
|
||||
}
|
||||
await DispatchSocketIo(sio);
|
||||
}
|
||||
else
|
||||
{
|
||||
var bin = msg.Value.Bytes;
|
||||
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
|
||||
{
|
||||
bin = bin.AsSpan(1).ToArray();
|
||||
}
|
||||
pendingAttachments.Add(bin);
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"[ws-rx-bin] viewer={Vid} binLen={Len} pendingFrame={Pending} attachCount={AttachCount}",
|
||||
ViewerId, bin.Length, pendingFrame?.EventName ?? "(null)", pendingAttachments.Count);
|
||||
}
|
||||
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
|
||||
{
|
||||
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
|
||||
pendingFrame = null;
|
||||
await DispatchSocketIo(assembled);
|
||||
}
|
||||
}
|
||||
await DispatchSocketIo(sio);
|
||||
}
|
||||
else
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
exitReason = $"throw:{ex.GetType().Name}:{ex.Message}";
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
var bin = msg.Value.Bytes;
|
||||
if (bin.Length > 0 && bin[0] == (byte)EngineIoPacketType.Message)
|
||||
{
|
||||
bin = bin.AsSpan(1).ToArray();
|
||||
}
|
||||
pendingAttachments.Add(bin);
|
||||
if (pendingFrame is not null && pendingAttachments.Count == pendingFrame.AttachmentCount)
|
||||
{
|
||||
var assembled = pendingFrame.WithAttachments(pendingAttachments.ToArray());
|
||||
pendingFrame = null;
|
||||
await DispatchSocketIo(assembled);
|
||||
}
|
||||
_log.LogWarning(
|
||||
"[ws-loop-exit] viewer={Vid} reason={Reason} wsState={State} cancelled={Cancelled}",
|
||||
ViewerId, exitReason, _ws.State, cancellation.IsCancellationRequested);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,6 +182,12 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||
{
|
||||
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}",
|
||||
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock);
|
||||
}
|
||||
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
|
||||
}
|
||||
|
||||
@@ -180,6 +219,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
case WireConstants.AliveEvent when frame.BinaryAttachments.Count == 1:
|
||||
await HandleAliveEventAsync(frame);
|
||||
return;
|
||||
case WireConstants.HandEvent when frame.BinaryAttachments.Count == 1:
|
||||
await HandleHandEventAsync(frame);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_log.LogDebug("RealParticipant viewer={Vid}: dropping SIO event={Event}", ViewerId, frame.EventName);
|
||||
@@ -198,14 +240,25 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
}
|
||||
|
||||
bool shouldDispatch = true;
|
||||
bool ackSent = false;
|
||||
long? ackArg = null;
|
||||
if (env.PubSeq.HasValue)
|
||||
{
|
||||
shouldDispatch = Inbound.Observe(env.PubSeq.Value);
|
||||
if (frame.AckId.HasValue)
|
||||
{
|
||||
await SendSioAckAsync(frame.AckId.Value, env.PubSeq.Value);
|
||||
ackSent = true;
|
||||
ackArg = env.PubSeq.Value;
|
||||
}
|
||||
}
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"[sio-in] viewer={Vid} uri={Uri} pubSeq={Pseq} ackId={AckId} dispatch={Dispatch} ackSent={AckSent} ackArg={AckArg} highWaterMark={Hwm}",
|
||||
ViewerId, env.Uri, env.PubSeq, frame.AckId, shouldDispatch, ackSent, ackArg, Inbound.HighWaterMark);
|
||||
}
|
||||
|
||||
if (!shouldDispatch) return;
|
||||
|
||||
if (FrameEmitted is not null)
|
||||
@@ -219,6 +272,93 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ack <c>hand</c> events from the client so the client's <c>stockEmitMessageMgr</c>
|
||||
/// drains and subsequent emits transmit.
|
||||
/// <para>
|
||||
/// Wire shape: hand events are <b>not encrypted</b> on the wire — the client's
|
||||
/// <c>RealTimeNetworkAgent.CreatePackEmitHandData:815-817</c> calls only
|
||||
/// <c>MessagePackSerializer.Serialize(JsonMapper.ToJson(list))</c>, skipping the
|
||||
/// <c>CryptAES.encryptForNode</c> wrap that <c>CreatePackEmitData</c> applies to <c>msg</c>
|
||||
/// events. The msgpack-wrapped string is a JSON array of the form
|
||||
/// <c>[uri_int, viewerId, udid, pubSeq, ...emit_params]</c> — see
|
||||
/// <c>EmitMsgUriPack:1456-1458</c> which inserts <c>pubSeq</c> at index 3 of the list
|
||||
/// for <c>isHandData</c> emits. The dict's top-level <c>pubSeq</c> stays client-local
|
||||
/// (used by its stockEmitMessageMgr.GetSelectData lookup); it's NOT on the wire.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// In scripted/Bot mode the server has no opponent to forward touches to; ack-only is
|
||||
/// correct. PvP-side forwarding semantics are unverified — see
|
||||
/// <c>docs/audits/battle-node-sio-events-2026-06-02.md</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fire-and-forget hand frames (TOUCH_URI / SELECT_OBJECT_URI / TURN_END_READY_URI) arrive
|
||||
/// with no ack-id; we swallow without decoding. Stocked variants (SELECT_SKILL_URI /
|
||||
/// SLIDE_OBJECT_URI) arrive with an ack-id and must be acked with the body's <c>pubSeq</c>
|
||||
/// or the client's emit queue softlocks behind them.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private async Task HandleHandEventAsync(SocketIoFrame frame)
|
||||
{
|
||||
if (!frame.AckId.HasValue)
|
||||
{
|
||||
// Fire-and-forget; no queue-blocking risk. Swallow without decoding.
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// No NodeCrypto.DecryptForNode here — hand events are unencrypted on the wire.
|
||||
var json = MessagePack.MessagePackSerializer.Deserialize<string>(frame.BinaryAttachments[0]);
|
||||
if (_diagnosticLogging)
|
||||
{
|
||||
_log.LogInformation(
|
||||
"[hand-rx] viewer={Vid} ackId={AckId} bodyLen={Len} body={Body}",
|
||||
ViewerId, frame.AckId, json.Length,
|
||||
json.Length > 200 ? json.Substring(0, 200) + "..." : json);
|
||||
}
|
||||
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
long? pubSeq = null;
|
||||
var rootKind = doc.RootElement.ValueKind;
|
||||
if (rootKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
// Prod shape: [uri_int, viewerId, udid, pubSeq, ...emit_params].
|
||||
var arr = doc.RootElement;
|
||||
if (arr.GetArrayLength() > 3
|
||||
&& arr[3].ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
{
|
||||
pubSeq = arr[3].GetInt64();
|
||||
}
|
||||
}
|
||||
else if (rootKind == System.Text.Json.JsonValueKind.Object
|
||||
&& doc.RootElement.TryGetProperty("pubSeq", out var psEl)
|
||||
&& psEl.ValueKind == System.Text.Json.JsonValueKind.Number)
|
||||
{
|
||||
// Defensive: dict root with top-level pubSeq isn't what the client sends today,
|
||||
// but the StockHandData dict shape exists on the client side and a future
|
||||
// wire-format change could expose it. Cheap to handle.
|
||||
pubSeq = psEl.GetInt64();
|
||||
}
|
||||
|
||||
if (pubSeq is null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"RealParticipant viewer={Vid}: 'hand' event ackId={AckId} body has no extractable pubSeq " +
|
||||
"(rootKind={Kind}, bodyLen={Len}); acking with 0 as fallback.",
|
||||
ViewerId, frame.AckId, rootKind, json.Length);
|
||||
await SendSioAckAsync(frame.AckId.Value, 0);
|
||||
return;
|
||||
}
|
||||
await SendSioAckAsync(frame.AckId.Value, pubSeq.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning(ex,
|
||||
"RealParticipant viewer={Vid}: failed to decode 'hand' event body; not acking. ackId={AckId}",
|
||||
ViewerId, frame.AckId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleAliveEventAsync(SocketIoFrame frame)
|
||||
{
|
||||
try
|
||||
@@ -306,9 +446,26 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
|
||||
do
|
||||
{
|
||||
try { result = await _ws.ReceiveAsync(buffer, ct); }
|
||||
catch (OperationCanceledException) { return null; }
|
||||
catch (WebSocketException) { return null; }
|
||||
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (_diagnosticLogging)
|
||||
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=OperationCanceled wsState={State}", ViewerId, _ws.State);
|
||||
return null;
|
||||
}
|
||||
catch (WebSocketException wsex)
|
||||
{
|
||||
if (_diagnosticLogging)
|
||||
_log.LogWarning(wsex, "[ws-recv-exit] viewer={Vid} reason=WebSocketException wsState={State} errCode={ErrCode}",
|
||||
ViewerId, _ws.State, wsex.WebSocketErrorCode);
|
||||
return null;
|
||||
}
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
if (_diagnosticLogging)
|
||||
_log.LogWarning("[ws-recv-exit] viewer={Vid} reason=ClientCloseFrame wsState={State} closeStatus={Status} desc={Desc}",
|
||||
ViewerId, _ws.State, result.CloseStatus, result.CloseStatusDescription);
|
||||
return null;
|
||||
}
|
||||
ms.Write(buffer, 0, result.Count);
|
||||
} while (!result.EndOfMessage);
|
||||
return (ms.ToArray(), result.MessageType == WebSocketMessageType.Text);
|
||||
|
||||
@@ -23,12 +23,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
|
||||
public MatchContext Context { get; } = new(
|
||||
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 (matches the
|
||||
// hardcoded OppoDeckCount that ScriptedProfiles.OpponentMatchedProfile shipped).
|
||||
// 30 dummy card ids so oppoCtx.SelfDeckCardIds.Count == 30 — prod frame[2] (Matched)
|
||||
// shipped OppoDeckCount: 30.
|
||||
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
|
||||
// BattleStart opponent half: ClassId/CharaId from ScriptedProfiles.OpponentBattleStartProfile.
|
||||
// BattleStart opponent half (frame[5]): ClassId/CharaId both "8" (neutral test class).
|
||||
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015",
|
||||
// Matched opponent half: cosmetic fields from ScriptedProfiles.OpponentMatchedProfile.
|
||||
// Matched opponent half (frame[2]): cosmetic fields from the prod capture.
|
||||
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
|
||||
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
|
||||
BattleType: 0);
|
||||
@@ -37,9 +37,12 @@ public sealed class ScriptedBotParticipant : IBattleParticipant
|
||||
|
||||
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct)
|
||||
{
|
||||
// v1.2 behavior: react to the player's TurnEnd / TurnEndFinal with the
|
||||
// three-frame burst. Everything else is silently swallowed.
|
||||
if (envelope.Uri is NetworkBattleUri.TurnEnd or NetworkBattleUri.TurnEndFinal)
|
||||
// React to the player's TurnEnd with the three-frame burst (TurnStart / TurnEnd /
|
||||
// Judge) — that's the v1.2 "scripted bot takes its turn" behavior. Everything else
|
||||
// (including TurnEndFinal) is silently swallowed: TurnEndFinal is the player's
|
||||
// game-end signal and is handled directly by the BattleSession dispatch arm, which
|
||||
// pushes BattleFinish per-side; the bot doesn't need to react.
|
||||
if (envelope.Uri is NetworkBattleUri.TurnEnd)
|
||||
{
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnStart(), ct).ConfigureAwait(false);
|
||||
await EmitAsync(ScriptedLifecycle.BuildOpponentTurnEnd(), ct).ConfigureAwait(false);
|
||||
|
||||
@@ -11,23 +11,17 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
public class ArenaTwoPickBattleController : SVSimController
|
||||
{
|
||||
private readonly IArenaTwoPickService _svc;
|
||||
private readonly IMatchingBridge _matching;
|
||||
private readonly IMatchContextBuilder _matchContextBuilder;
|
||||
private readonly IMatchingPairUpService _pairUp;
|
||||
private readonly BattleNodeOptions _battleNodeOptions;
|
||||
private readonly IMatchingResolver _resolver;
|
||||
|
||||
public ArenaTwoPickBattleController(
|
||||
IArenaTwoPickService svc,
|
||||
IMatchingBridge matching,
|
||||
IMatchContextBuilder matchContextBuilder,
|
||||
IMatchingPairUpService pairUp,
|
||||
BattleNodeOptions battleNodeOptions)
|
||||
IMatchingResolver resolver)
|
||||
{
|
||||
_svc = svc;
|
||||
_matching = matching;
|
||||
_matchContextBuilder = matchContextBuilder;
|
||||
_pairUp = pairUp;
|
||||
_battleNodeOptions = battleNodeOptions;
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
[HttpPost("do_matching")]
|
||||
@@ -37,59 +31,21 @@ public class ArenaTwoPickBattleController : SVSimController
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!TryGetViewerId(out var vid)) return Unauthorized();
|
||||
// Accept "1" or "true" (case-insensitive) as opt-in for the legacy Scripted path.
|
||||
// ASP.NET's default bool binder rejects "1", so do a permissive parse here.
|
||||
// The server-side BattleNodeOptions.SoloDefaultsToScripted flag is the other
|
||||
// route — it bypasses pair-up for every solo poll, useful when the live client
|
||||
// (which can't append query params) needs a Scripted match.
|
||||
var useScripted = (scripted is not null
|
||||
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase)))
|
||||
|| _battleNodeOptions.SoloDefaultsToScripted;
|
||||
// Accept "1" or "true" (case-insensitive) as per-request opt-in for the Scripted
|
||||
// path. ASP.NET's default bool binder rejects "1", so parse permissively here.
|
||||
// BattleNodeOptions.SoloDefaultsToScripted is the process-wide equivalent and is
|
||||
// applied inside the resolver.
|
||||
var scriptedOptIn = scripted is not null
|
||||
&& (scripted == "1" || string.Equals(scripted, "true", StringComparison.OrdinalIgnoreCase));
|
||||
try
|
||||
{
|
||||
var ctx = await _matchContextBuilder.BuildForTwoPickAsync(vid);
|
||||
|
||||
if (useScripted)
|
||||
{
|
||||
var scriptedMatch = _matching.RegisterBattle(
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||
p2: null,
|
||||
SVSim.BattleNode.Sessions.BattleType.Scripted);
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = 3004,
|
||||
BattleId = scriptedMatch.BattleId,
|
||||
NodeServerUrl = scriptedMatch.NodeServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
var paired = await _pairUp.TryPairAsync(
|
||||
"arena_two_pick_battle",
|
||||
new SVSim.BattleNode.Bridge.BattlePlayer(vid, ctx),
|
||||
ct);
|
||||
if (paired is null)
|
||||
{
|
||||
// 3002 = RC_BATTLE_MATCHING_RETRY: client polls again. 3001 is ILLEGAL
|
||||
// and shows an error dialog on the client side. node_server_url must be
|
||||
// present (the client's DoMatchingBase.SettingDoMatchingData calls
|
||||
// .ToString() on it without a Keys.Contains guard); prod sends "" while
|
||||
// waiting and the real URL only on SUCCEEDED. battle_id stays absent
|
||||
// (its accessor IS guarded).
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = 3002,
|
||||
NodeServerUrl = "",
|
||||
});
|
||||
}
|
||||
|
||||
// Owner (first arriver, cache hit) gets 3007 = RC_BATTLE_MATCHING_SUCCEEDED_OWNER;
|
||||
// joiner (second arriver who triggered the pair) gets 3004 = RC_BATTLE_MATCHING_SUCCEEDED.
|
||||
// See PairUpResult docs for why this split is observationally inert in TK2 today.
|
||||
var r = await _resolver.ResolveAsync("arena_two_pick_battle", new BattlePlayer(vid, ctx), scriptedOptIn, ct);
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = paired.IsOwner ? 3007 : 3004,
|
||||
BattleId = paired.Match.BattleId,
|
||||
NodeServerUrl = paired.Match.NodeServerUrl,
|
||||
MatchingState = r.MatchingState,
|
||||
BattleId = r.BattleId,
|
||||
NodeServerUrl = r.NodeServerUrl,
|
||||
});
|
||||
}
|
||||
catch (ArenaTwoPickException ex)
|
||||
|
||||
@@ -23,23 +23,20 @@ namespace SVSim.EmulatedEntrypoint.Controllers;
|
||||
[Authorize(AuthenticationSchemes = SteamAuthenticationConstants.SchemeName)]
|
||||
public sealed class RankBattleController : ControllerBase
|
||||
{
|
||||
private readonly IMatchingPairUpService _pairUp;
|
||||
private readonly IMatchingBridge _bridge;
|
||||
private readonly IMatchingResolver _resolver;
|
||||
private readonly IBattleSessionStore _sessionStore;
|
||||
private readonly IMatchContextBuilder _ctxBuilder;
|
||||
private readonly IBotRoster _botRoster;
|
||||
private readonly ILogger<RankBattleController> _log;
|
||||
|
||||
public RankBattleController(
|
||||
IMatchingPairUpService pairUp,
|
||||
IMatchingBridge bridge,
|
||||
IMatchingResolver resolver,
|
||||
IBattleSessionStore sessionStore,
|
||||
IMatchContextBuilder ctxBuilder,
|
||||
IBotRoster botRoster,
|
||||
ILogger<RankBattleController> log)
|
||||
{
|
||||
_pairUp = pairUp;
|
||||
_bridge = bridge;
|
||||
_resolver = resolver;
|
||||
_sessionStore = sessionStore;
|
||||
_ctxBuilder = ctxBuilder;
|
||||
_botRoster = botRoster;
|
||||
@@ -135,33 +132,16 @@ public sealed class RankBattleController : ControllerBase
|
||||
return Ok(new DoMatchingResponseDto { MatchingState = 3001, NodeServerUrl = "" });
|
||||
}
|
||||
|
||||
var paired = await _pairUp.TryPairAsync(mode, new BattlePlayer(vid, ctx), ct);
|
||||
|
||||
if (paired is null)
|
||||
{
|
||||
// Parked. 3002 RETRY. node_server_url must be present as empty string —
|
||||
// client's DoMatchingBase parser calls .ToString() without a guard.
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = 3002,
|
||||
NodeServerUrl = "",
|
||||
});
|
||||
}
|
||||
|
||||
// Owner cache-pickup → 3007 (PvP) or 3011 (AI fallback).
|
||||
// Joiner (only PvP) → 3004.
|
||||
var state = paired switch
|
||||
{
|
||||
{ IsAiFallback: true } => 3011,
|
||||
{ IsOwner: true } => 3007,
|
||||
_ => 3004,
|
||||
};
|
||||
// Rank battle has no ?scripted=1 query opt-in (no live capture has shown such a
|
||||
// param on the rank URLs). The process-wide BattleNodeOptions.SoloDefaultsToScripted
|
||||
// toggle is the only scripted entry point and is honored inside the resolver.
|
||||
var r = await _resolver.ResolveAsync(mode, new BattlePlayer(vid, ctx), scriptedOptIn: false, ct);
|
||||
|
||||
return Ok(new DoMatchingResponseDto
|
||||
{
|
||||
MatchingState = state,
|
||||
BattleId = paired.Match.BattleId,
|
||||
NodeServerUrl = paired.Match.NodeServerUrl,
|
||||
MatchingState = r.MatchingState,
|
||||
BattleId = r.BattleId,
|
||||
NodeServerUrl = r.NodeServerUrl,
|
||||
// Placeholder per spec § Out of scope — per-battle card-master split is deferred.
|
||||
CardMasterId = 0,
|
||||
});
|
||||
|
||||
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),
|
||||
}));
|
||||
builder.Services.AddSingleton<IMatchingPairUpService, InProcessPairUp>();
|
||||
// Single resolver shared by every /do_matching family controller. Owns the scripted-
|
||||
// flag short-circuit + the pair-up → matching_state mapping. Singleton: stateless,
|
||||
// all deps are singletons too.
|
||||
builder.Services.AddSingleton<IMatchingResolver, MatchingResolver>();
|
||||
// Phase 3: bot roster used by RankBattleController.AiStart to compose oppo_info.
|
||||
// Transient because BotRoster depends on the transient IGlobalsRepository.
|
||||
builder.Services.AddTransient<IBotRoster, BotRoster>();
|
||||
@@ -156,7 +160,7 @@ public class Program
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
// Update database (skipped for non-relational providers, e.g. InMemory in tests, and
|
||||
// skipped under the "Testing" environment where the test fixture has already called
|
||||
// EnsureCreated against a SQLite in-memory DB — the Postgres migrations would fail there).
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
}
|
||||
},
|
||||
"BattleNode": {
|
||||
"SoloDefaultsToScripted": false
|
||||
"SoloDefaultsToScripted": false,
|
||||
"DiagnosticLogging": false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -281,9 +281,10 @@ public class BattleNodeFlowTests
|
||||
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
var aBody = (RawBody)aFinish.Body;
|
||||
var bBody = (RawBody)bFinish.Body;
|
||||
// BattleResult.Lose = 0, Win = 1.
|
||||
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Lose));
|
||||
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
|
||||
// BattleResult.RetireLose = 106 (retirer), RetireWin = 105 (survivor). Player-
|
||||
// perspective codes per the FinishBattleEffect trace.
|
||||
Assert.That((long)aBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireLose));
|
||||
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.RetireWin));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -319,11 +320,11 @@ public class BattleNodeFlowTests
|
||||
// Abruptly close A's WS (no Retire).
|
||||
await clientA.DisposeAsync();
|
||||
|
||||
// B should receive BattleFinish(Win) within a few seconds.
|
||||
// B should receive BattleFinish(DisconnectWin) within a few seconds.
|
||||
var bFinish = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(bFinish.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
var bBody = (RawBody)bFinish.Body;
|
||||
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.Win));
|
||||
Assert.That((long)bBody.Entries["result"]!, Is.EqualTo((long)SVSim.BattleNode.Protocol.BattleResult.DisconnectWin));
|
||||
|
||||
// PendingBattle should be evicted by the second arriver's RemovePending.
|
||||
var store = factory.Services.GetRequiredService<SVSim.BattleNode.Sessions.IBattleSessionStore>();
|
||||
@@ -368,21 +369,28 @@ public class BattleNodeFlowTests
|
||||
// NOTE: ConsumeHandshakeAsync is NOT called here. The EIO Open frame is sent inside
|
||||
// RealParticipant.RunAsync, which only runs once the session is constructed by the
|
||||
// SECOND arriver. The first arriver who times out never receives that frame — the
|
||||
// handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, the
|
||||
// handler's HTTP method returns, and the TestServer-side WS shuts down. ReceiveAsync
|
||||
// observes the shutdown either by returning a Close message or throwing.
|
||||
// handler parks them in AwaitSessionFinishedAsync, the waiting-room timer fires, and
|
||||
// the polite-close path emits an EIO "1" Close text frame followed by a clean
|
||||
// WebSocket close handshake before the handler returns.
|
||||
bool politeFrameObserved = false;
|
||||
bool closeObserved = false;
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var buf = new byte[1024];
|
||||
while (!closeObserved && sw.Elapsed < TimeSpan.FromSeconds(65))
|
||||
{
|
||||
try
|
||||
{
|
||||
var rr = await wsA.ReceiveAsync(new ArraySegment<byte>(new byte[1024]), ct);
|
||||
var rr = await wsA.ReceiveAsync(new ArraySegment<byte>(buf), ct);
|
||||
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Close)
|
||||
{
|
||||
closeObserved = true;
|
||||
break;
|
||||
}
|
||||
if (rr.MessageType == System.Net.WebSockets.WebSocketMessageType.Text)
|
||||
{
|
||||
var text = System.Text.Encoding.UTF8.GetString(buf, 0, rr.Count);
|
||||
if (text == "1") politeFrameObserved = true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -391,6 +399,8 @@ public class BattleNodeFlowTests
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.That(politeFrameObserved, Is.True,
|
||||
"A's WS should receive an EIO '1' Close text frame before teardown (polite-close contract).");
|
||||
Assert.That(closeObserved, Is.True,
|
||||
"A's WS should close (or ReceiveAsync should fail) after the waiting-room timeout.");
|
||||
wsA.Dispose();
|
||||
|
||||
@@ -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]
|
||||
public void BattleFinishBody_SerializesResultAndResultCode_AsNumericWireValues()
|
||||
{
|
||||
// The wire field is the int code (Win=1); BattleResult uses JsonNumberEnumConverter
|
||||
// to override the default JsonStringEnumConverter (which would emit "Win" instead).
|
||||
var body = new BattleFinishBody(Result: BattleResult.Win);
|
||||
// The wire field is the int RESULT_CODE (LifeWin=101); BattleResult uses
|
||||
// JsonNumberEnumConverter to override the default JsonStringEnumConverter (which
|
||||
// would emit "LifeWin" instead).
|
||||
var body = new BattleFinishBody(Result: BattleResult.LifeWin);
|
||||
|
||||
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
|
||||
|
||||
Assert.That(node["result"]!.GetValue<int>(), Is.EqualTo(1));
|
||||
Assert.That(node["result"]!.GetValue<int>(), Is.EqualTo(101));
|
||||
Assert.That(node["resultCode"]!.GetValue<int>(), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BattleFinishBody_LoseAndConsistency_SerializeAsZeroAndTwo()
|
||||
{
|
||||
// Lock the wire values per BattleFinishResponsProcessing's switch (0=LOSE, 2=CONSISTENCY).
|
||||
var lose = (JsonObject)JsonSerializer.SerializeToNode(new BattleFinishBody(BattleResult.Lose))!;
|
||||
var consistency = (JsonObject)JsonSerializer.SerializeToNode(new BattleFinishBody(BattleResult.Consistency))!;
|
||||
|
||||
Assert.That(lose["result"]!.GetValue<int>(), Is.EqualTo(0));
|
||||
Assert.That(consistency["result"]!.GetValue<int>(), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AlivePushBody_SerializesScsAndOcs_AndDoesNotIncludeResultCode()
|
||||
{
|
||||
|
||||
@@ -82,6 +82,48 @@ public class BattleSessionDispatchTests
|
||||
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scripted_TurnEndFinal_forwards_envelope_and_pushes_paired_BattleFinish()
|
||||
{
|
||||
// Unified TurnEndFinal handling: forward the envelope to other (matches prod
|
||||
// capture battle-traffic_tk2_regular.ndjson:273) + push BattleFinish per-side
|
||||
// with player-perspective codes (LifeWin to winner, LifeLose to loser).
|
||||
// In Scripted mode the "loser" is a ScriptedBotParticipant; the loser-side
|
||||
// BattleFinish push is harmless (bot swallows non-TurnEnd URIs).
|
||||
var (s, a, b) = NewSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(3),
|
||||
"TurnEndFinal must produce: forwarded envelope + BattleFinish(LifeWin) to from + BattleFinish(LifeLose) to other.");
|
||||
|
||||
// Route 0: forwarded TurnEndFinal envelope to other.
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
|
||||
|
||||
// Route 1: BattleFinish(LifeWin) to from (the winner who declared the final turn).
|
||||
Assert.That(routes[1].Target, Is.SameAs(a));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[1].NoStock, Is.True);
|
||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.LifeWin),
|
||||
"Winner gets LifeWin (101) — player-perspective: 'I won by life' → WIN UI.");
|
||||
|
||||
// Route 2: BattleFinish(LifeLose) to other (the loser).
|
||||
Assert.That(routes[2].Target, Is.SameAs(b));
|
||||
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[2].NoStock, Is.True);
|
||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[2].Frame.Body;
|
||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.LifeLose),
|
||||
"Loser gets LifeLose (102) — player-perspective: 'I lost by life' → LOSE UI.");
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal),
|
||||
"Session must transition to Terminal so the RunAsync cascade doesn't synthesize a second BattleFinish.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Handshake_dispatch_reads_per_participant_Phase_not_session_Phase()
|
||||
{
|
||||
@@ -154,28 +196,46 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Retire_pushes_BattleFinish_no_contest_terminates()
|
||||
public void Retire_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose),
|
||||
"Retirer gets RetireLose=106 — player-perspective: 'I lost by retire'.");
|
||||
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[1].NoStock, Is.True);
|
||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin),
|
||||
"Survivor gets RetireWin=105. In Scripted mode the bot swallows it; in PvP the opponent renders 'opponent retired'.");
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Kill_pushes_BattleFinish_no_contest_terminates()
|
||||
public void Kill_pushes_paired_BattleFinish_RetireLose_to_from_and_RetireWin_to_other()
|
||||
{
|
||||
var (s, a, _) = NewSession();
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(routes[0].NoStock, Is.True);
|
||||
var loseBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[0].Frame.Body;
|
||||
Assert.That(loseBody.Result, Is.EqualTo(BattleResult.RetireLose));
|
||||
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
var winBody = (SVSim.BattleNode.Protocol.Bodies.BattleFinishBody)routes[1].Frame.Body;
|
||||
Assert.That(winBody.Result, Is.EqualTo(BattleResult.RetireWin));
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
@@ -360,17 +420,29 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnEndFinal_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
|
||||
public void Pvp_TurnEndFinal_from_A_forwards_envelope_to_B_and_pushes_paired_BattleFinish()
|
||||
{
|
||||
// Same unified handling as Scripted — A is the winner, B is the loser.
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndFinal));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(4));
|
||||
Assert.That(routes.Select(r => r.Frame.Uri).Distinct(),
|
||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
Assert.That(routes.Count, Is.EqualTo(3));
|
||||
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndFinal));
|
||||
|
||||
Assert.That(routes[1].Target, Is.SameAs(a));
|
||||
Assert.That(routes[1].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.LifeWin));
|
||||
|
||||
Assert.That(routes[2].Target, Is.SameAs(b));
|
||||
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
|
||||
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -386,7 +458,7 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_Retire_from_A_pushes_BattleFinish_Lose_to_A_and_Win_to_B()
|
||||
public void Pvp_Retire_from_A_pushes_RetireLose_to_A_and_RetireWin_to_B()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -398,9 +470,9 @@ public class BattleSessionDispatchTests
|
||||
var aRoute = routes.Single(r => ReferenceEquals(r.Target, a));
|
||||
var bRoute = routes.Single(r => ReferenceEquals(r.Target, b));
|
||||
Assert.That(aRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Lose));
|
||||
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
||||
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.Win));
|
||||
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
||||
Assert.That(aRoute.NoStock, Is.True);
|
||||
Assert.That(bRoute.NoStock, Is.True);
|
||||
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal));
|
||||
@@ -420,15 +492,20 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scripted_Retire_still_pushes_BattleFinish_Win_to_sender_only()
|
||||
public void Scripted_Retire_pushes_RetireLose_to_player_and_RetireWin_to_bot()
|
||||
{
|
||||
// Regression guard — Phase 1 behavior preserved for Scripted.
|
||||
var (s, a, _) = NewSession();
|
||||
// Unified with PvP — paired BattleFinish per-side. In Scripted mode the "loser"
|
||||
// is a ScriptedBotParticipant; its loser-side push is swallowed (it only reacts
|
||||
// to TurnEnd). The wire-correct codes are still emitted in case future work
|
||||
// wants to inspect them or run a real two-real-participant session.
|
||||
var (s, a, b) = NewSession();
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.Win));
|
||||
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
||||
}
|
||||
|
||||
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
|
||||
@@ -558,9 +635,11 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Bot_Retire_pushes_BattleFinish_Win_to_sender()
|
||||
public void Bot_Retire_pushes_paired_BattleFinish_RetireLose_to_player_RetireWin_to_bot()
|
||||
{
|
||||
var (s, a, _) = NewBotSession();
|
||||
// Unified Retire/Kill dispatch — same paired push as Scripted and PvP.
|
||||
// NoOpBotParticipant swallows its push.
|
||||
var (s, a, b) = NewBotSession();
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
|
||||
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
|
||||
@@ -568,9 +647,11 @@ public class BattleSessionDispatchTests
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Retire));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes.Count, Is.EqualTo(2));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
|
||||
Assert.That(((BattleFinishBody)routes[0].Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
|
||||
Assert.That(routes[1].Target, Is.SameAs(b));
|
||||
Assert.That(((BattleFinishBody)routes[1].Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
|
||||
}
|
||||
|
||||
private static (BattleSession, FakeRealParticipant, FakeRealParticipant) NewPvpSession()
|
||||
|
||||
@@ -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]
|
||||
public async Task PushAsync_TurnEndFinal_fires_three_FrameEmitted_in_order()
|
||||
public async Task PushAsync_TurnEndFinal_does_NOT_fire_burst()
|
||||
{
|
||||
// Same burst as TurnEnd — TurnEndFinal is the game-ending variant but the
|
||||
// bot's response shape is unchanged for v1.2 behaviour preservation.
|
||||
// TurnEndFinal is the game-end signal — owned by BattleSession's TurnEndFinal
|
||||
// dispatch arm, which pushes BattleFinish per-side. The bot no longer reacts to
|
||||
// it; reacting would race the BattleFinish with the no-longer-needed 3-frame
|
||||
// burst. Only regular TurnEnd triggers the burst.
|
||||
var p = new ScriptedBotParticipant();
|
||||
var emitted = new List<NetworkBattleUri>();
|
||||
p.FrameEmitted += (env, _) => { emitted.Add(env.Uri); return Task.CompletedTask; };
|
||||
var fired = 0;
|
||||
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
|
||||
|
||||
await p.PushAsync(NewEnvelope(NetworkBattleUri.TurnEndFinal), noStock: false, CancellationToken.None);
|
||||
|
||||
Assert.That(emitted, Is.EqualTo(new[]
|
||||
{
|
||||
NetworkBattleUri.TurnStart,
|
||||
NetworkBattleUri.TurnEnd,
|
||||
NetworkBattleUri.Judge,
|
||||
}));
|
||||
Assert.That(fired, Is.EqualTo(0),
|
||||
"TurnEndFinal must not trigger the bot's burst — the dispatch arm pushes BattleFinish directly.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
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