Compare commits

...

26 Commits

Author SHA1 Message Date
gamer147
2d32051cc0 refactor(battlenode): key dispatch on OpponentIsAckOnly, drop per-frame BattleType switch
Behavior-identical; 231 BattleNode tests green with ZERO test changes.

The 10 handler arms no longer switch on BattleType:
- 4 Bot arms gate on the new FrameDispatchContext.OpponentIsAckOnly
  (Other is not IHasHandshakePhase) — the participant property the audit asked for.
- 6 relay arms drop the Type == Pvp guard; it was redundant with BothSidesAfterReady()
  (only a two-real-player session has both handshake phases). Its doc now records that.
- FrameDispatchContext.Type removed (+ the Type = Type in BuildContext). BattleSession.Type
  stays for the session-level drop cascade.

Zero test churn because the stubs already encode the split: FakeRealParticipant/ProbeParticipant
implement IHasHandshakePhase, the bot stub FakeParticipant doesn't, and NewBotSession uses it as
the opponent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:20:56 -04:00
gamer147
9ff8948903 docs(battlenode): document four latent low-tier hygiene hazards
Comment-only; behavior-preserving; 231 BattleNode tests green.

- OutboundSequencer._archive: name the unbounded-per-match growth + ack-prune point.
- NodeCrypto.BuildAes: SECURITY remarks on key-derived IV reuse + base64 entropy loss;
  warn against caching the session key.
- MatchContext/BattlePlayer: FOOTGUN notes on reference-based record equality over the deck list.
- RecordTokensFrom: TRUST note on isSelf/idx overwrite; name the idx>deckCount guard for
  untrusted peers (not added — trusted-LAN today).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:11:13 -04:00
gamer147
1007cf24d2 refactor(battlenode): type MatchContext.ClassId as CardClass enum (§C)
Behavior-preserving; full solution builds, 1013 tests green.

ClassId is the one genuinely-closed set of the three flagged stringly fields, so it
becomes a CardClass enum (1..8). Wire stays "1".."8": producer casts
(CardClass)run.ClassId, ServerBattleFrames renders via CardClassWire.ToWireValue().
RankBattleController's AI-start path drops a fragile int.TryParse(...)?:-1 for (int)cast.

CharaId (free-form leader/skin id, e.g. "5000123") and CountryCode (open-ended account
data) stay string with proper XML docs; CountryCodes.Korea/Japan name the captured values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 08:04:49 -04:00
gamer147
9b8a7f1e37 refactor(battlenode): name sender-only vs both-sides handshake checks (§D)
Behavior-preserving; 231 BattleNode tests green.

FrameDispatchContext.BothAfterReady() -> BothSidesAfterReady() (7 call sites). The
4 inline `SenderPhase == AfterReady` checks in TurnEndHandler/TurnEndFinalHandler now
read a new SenderIsAfterReady property. Both carry cross-referencing docs so the
Bot-arm (sender-only) vs PvP-arm (both-sides) distinction is explicit at the type.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:49:27 -04:00
gamer147
578d0a75ef refactor(battlenode): rename mode-id field off BattleType, add BattleModes (§D)
Behavior-preserving; 271 BattleNode/Matching/Services tests green, full solution builds.

"BattleType" meant two things: the Sessions.BattleType enum (Pvp/Bot) and an int
"mode id" field. Renamed the int field on MatchContext AND the BattleStartBody wire
DTO to BattleModeId (wire key stays "battleType" via JsonPropertyName), so BattleType
now means only the enum project-wide.

New Bridge/BattleModes.cs (TakeTwo = 11) replaces every 11 literal — both prod
MatchContextBuilder sites and the test fixtures/assertions. The arbitrary-passthrough
42 and bot 0 stay literal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:44:02 -04:00
gamer147
d119d2c277 refactor(battlenode): single-source MsgEnvelope envelope keys (§E)
Behavior-preserving; 231 BattleNode tests green.

The envelope key set was encoded three times (ReservedEnvelopeKeys, the ToJson
writes, the FromJson reads). Added a private nested MsgEnvelope.Keys with a const
per key; the reserved set, writes, and reads now all draw from it, so a key added
in one place but not another (letting a body key shadow an envelope field) can no
longer happen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:33:54 -04:00
gamer147
7e167b1cef refactor(battlenode): centralize inbound wire-key literals in WireKeys (§C)
Behavior-preserving; 231 BattleNode tests green (capture-conformance suite drives
real prod frames, so a wrong constant would fail).

New Sessions/Dispatch/WireKeys.cs holds the 28 inbound-body read keys (orderList /
keyAction / targetList / uList field names). KnownListBuilder, PlayActionsHandler,
EchoHandler, and BattleFrames.ExtractIdxList now read through it instead of repeated
inline strings, so a parse-side typo ("isSelf" vs "IsSelf") can no longer silently
degrade token resolution. Outbound [JsonPropertyName] attributes left as-is (already
single-source per DTO).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:30:02 -04:00
gamer147
3e8901eec3 refactor(battlenode): split BattleSessionPhase into HandshakePhase + SessionLifecycle
Behavior-preserving; 231 BattleNode tests green.

One enum conflated two axes. Split:
- HandshakePhase (per participant): AwaitingInitNetwork..AfterReady. On
  IHasHandshakePhase.Phase, FrameDispatchContext.SenderPhase, the handler gates.
- SessionLifecycle (per battle): Active | Terminal. On the renamed
  BattleSessionState.Lifecycle (was SessionPhase, defaulting to a handshake value)
  and BattleSession.Lifecycle (was Phase). Reads are only != Terminal, so the
  Active default is behavior-identical.

OpponentTurn was dead (never assigned) -> dropped. BattleSessionPhase deleted; the
two axes can no longer be cross-assigned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 07:21:59 -04:00
gamer147
7d4da69f22 refactor(battlenode): low-churn §B/§D/§E/§F quality cleanups
Behavior-preserving; 231 BattleNode tests green.

- §D: MsgEnvelope.Try -> RetryAttempt (drops keyword-escape; wire key stays "try");
  SocketIoFrame.AckResponse arg -> pubSeqEcho.
- §B: Gungnir.EmitInterval -> BattleNodeOptions.AliveEmitInterval (unused literal
  moved to its config home); deck-idx 4L -> InitialHand.Length + 1.
- §E: shared Wire.WireJsonOptions.CamelCase replaces the duplicated camelCase
  JsonSerializerOptions in EngineIoHandshake and MsgEnvelope.
- §F: do-NOT-consistency-fix polarity notes on TurnEndFinalHandler (From wins)
  and RetireKillHandler (From loses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:06:44 -04:00
gamer147
e70f32db79 refactor(battlenode): close §A boolean-blindness items (MinedToken, Stock, KeyActionType)
Behavior-preserving; 231 BattleNode tests green.

- MinedToken record struct replaces the transpose-prone (int Idx, long CardId,
  CardOwner IsSelf) tuple returned by KnownListBuilder.Mine*. Positional deconstruct
  keeps the Record*From call sites unchanged.
- enum Stock { Normal, Bypass } replaces the negative `bool noStock` on
  IBattleParticipant.PushAsync and DispatchRoute, threaded through both participants,
  BattleSession, and all handler construction sites.
- enum KeyActionType mirrors the client's SendKeyActionDataManager.KeyActionType;
  the StripKeyActionForOpponent guard compares named values, KeyActionEntry.Type is
  the enum (wire-identical via JsonNumberEnumConverter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:53:32 -04:00
gamer147
a3e445cf2f refactor(battle-node): replace int IsSelf with CardOwner enum on mined-token tuples
MineAddOps/MineChoicePicks/MineCopyTokens return types and all
extraction casts changed from int to CardOwner. The 4 routing
comparisons in BattleSessionState now read isSelf == CardOwner.Self
instead of isSelf == 1.

No wire or behavioral change — CardOwner was already in use on the
wire-facing side (OppoTargetEntry, UnapprovedCardEntry); this extends
it to the internal mining path so the bare-int transpose risk is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 22:25:01 -04:00
gamer147
564b1d678f fix(battle-node): collision-safe battle-id registration + viewer eviction
RegisterPending → TryRegisterPending (TryAdd instead of indexer) so
battle-id collisions return false instead of silently evicting a live
battle. MatchingBridge retries with fresh IDs on collision (max 5).

Before registering, EvictStaleForViewer removes any stale pending
battle the viewer left behind, enforcing the one-pending-per-viewer
invariant that was previously comment-asserted.

Store tests switched to per-test local stores to fix a race under
the assembly-wide ParallelScope.All.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 22:13:20 -04:00
gamer147
c6fb411861 fix(battle-node): dispose participants, unsubscribe events, filter catch
#5: BattleSession.RunAsync now unsubscribes FrameEmitted handlers
(-= OnFrameFromA/B) before termination and calls DisposeAsync on
both participants + the dispatch gate SemaphoreSlim afterward. This
unpins the session state from live delegates and releases the WS.

#6: Bare catch {} blocks replaced with filtered exception handlers
that silently swallow OperationCanceledException and WebSocketException
(expected at battle end) but log anything else at Warning. NREs and
other real bugs in handler threads are now visible instead of silently
eaten.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 22:00:28 -04:00
gamer147
99129c786c fix(battle-node): harden SIO parse + narrow Matched OppoId/Seed to int
#3: SocketIoFrame.Parse now range-checks the packet type char (was
unchecked cast — any char outside 0-6 produced an undefined enum
value) and uses int.TryParse for ack-id (was int.Parse — a >10-digit
ack-id threw OverflowException, tearing down the WS mid-game). Both
now throw ArgumentException consistently. The read loop in
RealParticipant wraps both EIO and SIO parse calls with try-catch so
a malformed frame is logged and skipped instead of killing the battle.

#4: MatchedSelfInfo/MatchedOppoInfo OppoId and Seed narrowed from
long to int. The client reads both with Convert.ToInt32 inside a
swallowing try/catch — any value > int.MaxValue silently dropped the
Matched event, preventing the battle from starting. Seed was already
int-range (BattleSeeds.Stable returns int); OppoId (viewer ID) is
~847M in captures, well under int.MaxValue. The narrowing cast now
happens explicitly in ServerBattleFrames.BuildMatched at the wire
boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 21:57:29 -04:00
gamer147
e9af7af1b8 fix(ranked-ai): randomize bot selection and seed for AI fallback matches
Bot roster pick was hashing (UserName, ClassId) — same player always
faced the same bot class. Now hashes battleId so different matches get
different opponents while retries of the same pending battle stay
consistent.

AI start response hardcoded Seed=0 for both sides, so the client's
deck shuffle/mulligan/draw RNG was deterministic every match. The
BattleNode's per-battle MasterSeed (Random.Shared) was never sent to
bot-mode clients because InitBattleHandler skips the Matched frame.
Now populates Seed with Random.Shared.Next() on the HTTP response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-04 21:49:43 -04:00
gamer147
77c99cc230 fix(battle-node): serialize per-session dispatch to stop cross-thread state race
In PvP a BattleSession subscribes to both participants' FrameEmitted, and each
RealParticipant raises it from its own WebSocket read loop -- two threads. The
dispatch path (ComputeFrames + the relay PushAsync calls) mutates shared,
non-thread-safe state: the BattleSessionState dictionaries (deck maps, post-swap
hands, idx->cardId reveal map). Concurrent frames from both players could corrupt
those dictionaries (InvalidOperationException / torn playSeq / wrong card identity).

Add a per-session SemaphoreSlim _dispatchGate around the whole HandleFrameAsync so
both read loops funnel through one critical section. ComputeFrames stays lock-free
(the direct-call test seam is single-threaded).

Analysis during the fix showed each OutboundSequencer is single-writer-per-instance
in steady state (A's loop only writes B's Outbound and vice-versa), so the live race
is the shared BattleSessionState, which the gate fully serializes.

TDD: BattleSessionDispatchConcurrencyTests drives both participants to AfterReady,
then fires TurnStart from both at once; the target PushAsync records peak in-flight
dispatches. Red (MaxConcurrent=2) before the gate, green (1) after.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:00:41 -04:00
gamer147
24180d5b4b refactor(battle-node): de-magic wire flags and scattered constants
Quality pass from the 2026-06-04 BattleNode review (audit in the outer
repo). All changes are behavior-preserving — identical wire bytes,
verified by the full 1008-test suite staying green.

- Name scattered magic numbers: crypto key/IV lengths, outbound-sequencer
  base, WS receive buffer / EIO ping / SID length, polite-close timeout,
  upgrade-credential keys, battle-id digit math, deterministic-turn spin.
- resultCode = 1 -> (int)ReceiveNodeResultCode.Success across body records.
- Pong "3" -> EngineIoPacketType.Pong; remove dead NoOpBotParticipant.Touch
  (replace with #pragma warning disable CS0067).
- Wire-flag enums, serialized as numbers via JsonNumberEnumConverter:
  turnState -> TurnState{First,Second}, isSelf -> CardOwner{Opponent,Self},
  open -> ChoiceVisibility{Hidden,Open}.
- isOfficial / isInvoke -> bool / bool? via new NumericBoolJsonConverter
  (reads/writes 0/1; TDD'd). Scoped to the BattleNode wire boundary only;
  MatchContext and the HTTP/AI-start path stay int (AI-start uses -1 as a
  sentinel, so it is not boolean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:46:09 -04:00
gamer147
ed88683fa0 merge: per-battle master seed + node-side deck shuffle
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:25:13 -04:00
gamer147
b229885259 refactor(battle-node): retire hardcoded BattleSeed + ReadyIdxChangeSeed
Both now derive per-battle from the master seed via BattleSeeds; only
animation/UI constants (ReadySpin, rank/battlePoint placeholders) remain in
BattleFrameDefaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:21:28 -04:00
gamer147
3f5d97cb2f feat(battle-node): derive Matched.seed + Ready.idxChangeSeed from master seed
InitBattle now emits Stable(master) as the shared effect seed and the master-
shuffled deck as selfDeck; Swap emits each recipient's per-side IdxChange seed.
BattleSession exposes + logs the master seed per battle for future replay.
Updated lifecycle/dispatch/integration tests (deck assertions now permutation-
based since selfDeck is shuffled).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:20:51 -04:00
gamer147
6f7fcfe28e feat(battle-node): per-battle master seed + node-side deck shuffle
GetOrSeedDeckMap now seeds from a Fisher-Yates shuffle of the deck keyed by the
per-battle MasterSeed, so the reveal map and the wire selfDeck share one
shuffled order. Updated the existing build-order test to the shuffle semantics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:14:14 -04:00
gamer147
11c98bf67b feat(battle-node): BattleSeeds — stable per-battle seed derivation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:13:06 -04:00
gamer147
75f3d8ea5b revert(battle-node): remove real-spin logic (CountHiddenDraws + per-frame spin)
Two-sided capture (data_dumps/captures/battle_test/rng, 2026-06-04) showed the
receiver already reproduces uList-relayed deck fetches (Hoverboard) and turn
draws on its own shared stream, so the emitted spin=1 double-cranked and desynced
the clients by 1. Residual spin is ~0 for the current card pool. Reverts 63cb324
and 617714e; back to the prior correct spin:0 behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:07:08 -04:00
gamer147
617714ebea feat(battle-node): emit real spin per-frame on forwarded PlayActions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:13:47 -04:00
gamer147
63cb3248b4 feat(battle-node): CountHiddenDraws — hidden shared-RNG draw tally for real spin
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:13:47 -04:00
gamer147
56652c7034 fix(battle-node): expand rank-battle deck by DeckCard.Count
BuildForRankBattleAsync projected deck.Cards.Select(c => c.Card.Id),
discarding Count. DeckCard is count-based (one row per unique card +
a Count), so a 3-copy card shipped to the node as a single in-battle
card -- matched decks showed 1 of each card instead of the real count.

Expand each row by its Count so SelfDeckCardIds carries one entry per
physical card. TwoPick path is unaffected (flat per-pick list).

Add a regression test seeding 3+2+1 copies (failed Expected 6/was 3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 15:09:14 -04:00
97 changed files with 1530 additions and 560 deletions

View File

@@ -0,0 +1,14 @@
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// Known values for <see cref="MatchContext.BattleModeId"/> — the prod do_matching battle-mode id,
/// forwarded verbatim onto the wire (<c>battleType</c> field on BattleStart). Names the otherwise
/// magic <c>11</c>. Distinct from the <see cref="Sessions.BattleType"/> enum (Pvp/Bot), which is the
/// session topology, not the game mode.
/// </summary>
public static class BattleModes
{
/// <summary>Take Two (TK2) — the two-pick draft mode the v1 captures were taken from. Prod
/// rank-battle frames carry the same value (see <c>MatchContextBuilder</c>).</summary>
public const int TakeTwo = 11;
}

View File

@@ -16,11 +16,18 @@ public sealed class BattleNodeOptions
/// </summary> /// </summary>
public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60); public TimeSpan WaitingRoomTimeout { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Cadence of the server→client alive ("Gungnir") keepalive emit. The driving timer/loop
/// (to live on <see cref="Sessions.BattleSession"/>) is deferred in v1; this is its future
/// home so the interval isn't a magic literal stranded on the <c>Gungnir</c> body factory.
/// </summary>
public TimeSpan AliveEmitInterval { get; set; } = TimeSpan.FromSeconds(5);
/// <summary> /// <summary>
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame /// 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 /// 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 /// 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); /// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, stock);
/// <c>[ws-rx-text]</c> / <c>[ws-rx-bin]</c> on every WS frame received at the transport /// <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 /// 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 /// (with WebSocket state + exception type when applicable). Default false — keeps

View File

@@ -1,5 +1,10 @@
namespace SVSim.BattleNode.Bridge; namespace SVSim.BattleNode.Bridge;
/// <summary>One player slot for a pending battle. Carries the viewer's identity and /// <summary>One player slot for a pending battle. Carries the viewer's identity and
/// the per-battle MatchContext snapshot built at do_matching time.</summary> /// the per-battle MatchContext snapshot built at do_matching time.
/// <para>FOOTGUN: this is a <c>record</c>, but <see cref="Context"/> transitively holds an
/// <c>IReadOnlyList&lt;long&gt;</c> (the deck), so the synthesized value-equality is REFERENCE-based
/// on that list — two BattlePlayers with equal deck *contents* compare unequal. Don't use
/// BattlePlayer / <see cref="MatchContext"/> as dictionary keys or <c>Distinct()</c> / <c>HashSet</c>
/// operands without first giving them content equality. Not exercised today.</para></summary>
public sealed record BattlePlayer(long ViewerId, MatchContext Context); public sealed record BattlePlayer(long ViewerId, MatchContext Context);

View File

@@ -0,0 +1,29 @@
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// A Shadowverse class (craft). The wire carries it as the stringified ordinal (<c>"1".."8"</c> on
/// the <c>classId</c> field); this enum replaces that stringly-typed value on
/// <see cref="MatchContext.ClassId"/> so the legal set lives in the type, not a trailing comment.
/// <see cref="None"/> covers an unset / placeholder context. Use <see cref="CardClassWire.ToWireValue"/>
/// to render the wire string.
/// </summary>
public enum CardClass
{
None = 0,
Forestcraft = 1,
Swordcraft = 2,
Runecraft = 3,
Dragoncraft = 4,
Shadowcraft = 5,
Bloodcraft = 6,
Havencraft = 7,
Portalcraft = 8,
}
/// <summary>Wire rendering for <see cref="CardClass"/>.</summary>
public static class CardClassWire
{
/// <summary>The <c>classId</c> wire value — the class ordinal as a string (<c>"1".."8"</c>,
/// <c>"0"</c> for <see cref="CardClass.None"/>), matching what the client sends/expects.</summary>
public static string ToWireValue(this CardClass cardClass) => ((int)cardClass).ToString();
}

View File

@@ -0,0 +1,13 @@
namespace SVSim.BattleNode.Bridge;
/// <summary>
/// Known values for <see cref="MatchContext.CountryCode"/>. NOT a closed set — the field is the
/// account's region code copied verbatim from viewer data (any value, possibly empty), and the node
/// never branches on it. These constants just name the values seen in the prod captures so test
/// fixtures and docs aren't sprinkled with bare <c>"KOR"</c>/<c>"JPN"</c> literals.
/// </summary>
public static class CountryCodes
{
public const string Korea = "KOR";
public const string Japan = "JPN";
}

View File

@@ -5,6 +5,9 @@ namespace SVSim.BattleNode.Bridge;
/// server-authored frame lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side /// server-authored frame lifecycle on WS connect. SVSim.BattleNode does not know how to build this — the HTTP-side
/// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching /// per-mode controller is the source. Snapshot semantics: cosmetic changes between matching
/// and WS connect have no effect on the in-battle render. /// and WS connect have no effect on the in-battle render.
/// <para>FOOTGUN: as a record holding <see cref="SelfDeckCardIds"/> (an IReadOnlyList), the
/// synthesized value-equality is reference-based on that list — see <see cref="BattlePlayer"/>.
/// Don't use as a dict key / <c>Distinct()</c> operand without content equality.</para>
/// </summary> /// </summary>
public sealed record MatchContext( public sealed record MatchContext(
// Player's drafted deck — exactly 30 entries, idx 1..30 paired with the chosen cardIds // Player's drafted deck — exactly 30 entries, idx 1..30 paired with the chosen cardIds
@@ -12,12 +15,25 @@ public sealed record MatchContext(
IReadOnlyList<long> SelfDeckCardIds, IReadOnlyList<long> SelfDeckCardIds,
// Player class + leader (BattleStartSelfInfo) // Player class + leader (BattleStartSelfInfo)
string ClassId, // "1".."8"
string CharaId, // "1".."8" — equals ClassId when no leader skin chosen /// <summary>The player's class. Rendered onto the wire <c>classId</c> as <c>"1".."8"</c> via
/// <see cref="CardClassWire.ToWireValue"/>; a closed set, so it's typed, not stringly.</summary>
CardClass ClassId,
/// <summary>Leader/skin id on the wire <c>charaId</c>. FREE-FORM, not a class enum: it's the
/// equipped leader-skin id (e.g. <c>"5000123"</c>) when one is chosen, else the class ordinal
/// (<c>"1".."8"</c>). Passed through verbatim — the node never interprets it.</summary>
string CharaId,
string CardMasterName, // current card-master, e.g. "card_master_node_10015" string CardMasterName, // current card-master, e.g. "card_master_node_10015"
// Player cosmetics (MatchedSelfInfo) // Player cosmetics (MatchedSelfInfo)
string CountryCode, // "KOR", "JPN", ...
/// <summary>Account region code, wire <c>country_code</c>. OPEN-ENDED account data (any value,
/// possibly empty); the node never branches on it. <see cref="CountryCodes"/> names the values
/// seen in captures.</summary>
string CountryCode,
string UserName, string UserName,
string SleeveId, string SleeveId,
string EmblemId, string EmblemId,
@@ -25,5 +41,8 @@ public sealed record MatchContext(
int FieldId, int FieldId,
int IsOfficial, // 0 or 1 int IsOfficial, // 0 or 1
// Battle-mode hint, currently TK2 == 11. Future modes populate their own value. // Battle-mode hint (the prod do_matching mode id). Named BattleModeId, NOT BattleType, to
int BattleType); // avoid colliding with the <see cref="Sessions.BattleType"/> enum (Pvp/Bot) — a different axis.
// Known values live in <see cref="BattleModes"/> (currently just TK2 == 11). Future modes add
// their own constant.
int BattleModeId);

View File

@@ -10,6 +10,11 @@ namespace SVSim.BattleNode.Bridge;
/// </summary> /// </summary>
public sealed class MatchingBridge : IMatchingBridge public sealed class MatchingBridge : IMatchingBridge
{ {
/// <summary>Battle id is two zero-padded decimal halves concatenated (e.g. "975695" + "075012").
/// The half-width and the draw bound must stay coupled: bound == 10^digits.</summary>
private const int BattleIdHalfDigits = 6;
private const int BattleIdHalfExclusiveMax = 1_000_000; // 10^BattleIdHalfDigits
private readonly IBattleSessionStore _store; private readonly IBattleSessionStore _store;
private readonly BattleNodeOptions _options; private readonly BattleNodeOptions _options;
@@ -19,19 +24,35 @@ public sealed class MatchingBridge : IMatchingBridge
_options = options; _options = options;
} }
private const int MaxIdRetries = 5;
public PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type) public PendingMatch RegisterBattle(BattlePlayer p1, BattlePlayer? p2, BattleType type)
{ {
ValidateContract(p1, p2, type); ValidateContract(p1, p2, type);
EvictStaleForViewer(p1.ViewerId);
if (p2 is not null) EvictStaleForViewer(p2.ViewerId);
// 12-digit decimal battle id mirrors the captures (e.g. "975695075012"). var halfFormat = "D" + BattleIdHalfDigits;
// Two unbiased 6-digit draws concatenated — RandomNumberGenerator.GetInt32 uses
// rejection sampling so the result is uniform on [0, 10^6).
var hi = RandomNumberGenerator.GetInt32(0, 1_000_000);
var lo = RandomNumberGenerator.GetInt32(0, 1_000_000);
var battleId = $"{hi:D6}{lo:D6}";
_store.RegisterPending(new PendingBattle(battleId, type, p1, p2)); for (var attempt = 0; attempt < MaxIdRetries; attempt++)
return new PendingMatch(battleId, _options.NodeServerUrl); {
var hi = RandomNumberGenerator.GetInt32(0, BattleIdHalfExclusiveMax);
var lo = RandomNumberGenerator.GetInt32(0, BattleIdHalfExclusiveMax);
var battleId = hi.ToString(halfFormat) + lo.ToString(halfFormat);
if (_store.TryRegisterPending(new PendingBattle(battleId, type, p1, p2)))
return new PendingMatch(battleId, _options.NodeServerUrl);
}
throw new InvalidOperationException(
$"Failed to mint a unique battle id after {MaxIdRetries} attempts.");
}
private void EvictStaleForViewer(long viewerId)
{
var stale = _store.TryFindPendingForViewer(viewerId);
if (stale is not null)
_store.RemovePending(stale.BattleId);
} }
private static void ValidateContract(BattlePlayer p1, BattlePlayer? p2, BattleType type) private static void ValidateContract(BattlePlayer p1, BattlePlayer? p2, BattleType type)

View File

@@ -33,6 +33,15 @@ namespace SVSim.BattleNode.Hosting;
/// </remarks> /// </remarks>
public sealed class BattleNodeWebSocketHandler public sealed class BattleNodeWebSocketHandler
{ {
/// <summary>Header/query key names carrying the upgrade credentials — the auth contract
/// with the client (and the loader that sets them). Single source of truth for both ends.</summary>
private const string BattleIdCredential = "BattleId";
private const string ViewerIdCredential = "viewerId";
/// <summary>Grace period for the close handshake on a bail-out path. A fresh, short timeout —
/// <c>ctx.RequestAborted</c> may already be canceled by the path that decided to bail.</summary>
private static readonly TimeSpan PoliteCloseTimeout = TimeSpan.FromSeconds(5);
private readonly IBattleSessionStore _store; private readonly IBattleSessionStore _store;
private readonly IWaitingRoom _waitingRoom; private readonly IWaitingRoom _waitingRoom;
private readonly BattleNodeOptions _options; private readonly BattleNodeOptions _options;
@@ -73,8 +82,8 @@ public sealed class BattleNodeWebSocketHandler
// for the WebSocket-only transport (not on the URL query string). Real clients // for the WebSocket-only transport (not on the URL query string). Real clients
// therefore send BattleId/viewerId as headers; the integration test sends them as // therefore send BattleId/viewerId as headers; the integration test sends them as
// query params for convenience. Check headers first, fall back to query. // query params for convenience. Check headers first, fall back to query.
var battleId = ReadCredential(ctx, "BattleId"); var battleId = ReadCredential(ctx, BattleIdCredential);
var encryptedViewerId = ReadCredential(ctx, "viewerId"); var encryptedViewerId = ReadCredential(ctx, ViewerIdCredential);
if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId)) if (string.IsNullOrEmpty(battleId) || string.IsNullOrEmpty(encryptedViewerId))
{ {
_log.LogWarning("WS upgrade missing BattleId or viewerId (header or query)."); _log.LogWarning("WS upgrade missing BattleId or viewerId (header or query).");
@@ -222,9 +231,7 @@ public sealed class BattleNodeWebSocketHandler
/// </summary> /// </summary>
private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId) private async Task TryPoliteCloseAsync(WebSocket ws, string reason, string battleId)
{ {
// Use a fresh, short timeout — ctx.RequestAborted may already be canceled by the using var cts = new CancellationTokenSource(PoliteCloseTimeout);
// path that decided to bail out, which would skip the close immediately.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try try
{ {
if (ws.State == WebSocketState.Open) if (ws.State == WebSocketState.Open)

View File

@@ -4,24 +4,20 @@ namespace SVSim.BattleNode.Lifecycle;
/// Default frame constants templated from TK2 prod captures, shared by the /// Default frame constants templated from TK2 prod captures, shared by the
/// server-authored battle-frame builders. Every value here originated in a real prod /// server-authored battle-frame builders. Every value here originated in a real prod
/// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them /// frame in <c>data_dumps/captures/battle-traffic_tk2_regular.ndjson</c>; pulling them
/// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable and gives /// out of <see cref="ServerBattleFrames"/> makes the magic numerics navigable. The shared effect
/// the seed a single source of truth instead of two duplicated literals. /// seed and the deck-shuffle/idxChangeSeed are now derived per-battle from a master seed (see
/// <see cref="BattleSeeds"/>) — only animation/UI constants remain here.
/// </summary> /// </summary>
internal static class BattleFrameDefaults internal static class BattleFrameDefaults
{ {
// Shared per the spec — selfInfo.seed and oppoInfo.seed always agree.
// From frame[2] (Matched).
public const long BattleSeed = 17_548_138L;
// From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these // From frame[5] (BattleStart). Hardcoded; see spec §Deferred plumbing — sourcing these
// from real per-viewer state needs a TK2 rank/battle-point tracker. // from real per-viewer state needs a TK2 rank/battle-point tracker.
public const string PlayerRank = "10"; public const string PlayerRank = "10";
public const string PlayerBattlePoint = "6270"; public const string PlayerBattlePoint = "6270";
// From frame[8] (Ready). Provenance is "what prod sent"; the client // From frame[8] (Ready). Provenance is "what prod sent"; the client doesn't validate. This is
// doesn't validate, but echoing matches the capture protects against // an animation crank value (shared-RNG spin), NOT gameplay randomness — both clients crank it
// a regression on a future tightening. // identically and stay synced, so it stays a constant. See the spin-rng audit.
public const int ReadyIdxChangeSeed = 771_335_280;
public const int ReadySpin = 243; public const int ReadySpin = 243;
/// <summary> /// <summary>
@@ -30,4 +26,9 @@ internal static class BattleFrameDefaults
/// the client's <c>JudgeOperation</c> doesn't read it. /// the client's <c>JudgeOperation</c> doesn't read it.
/// </summary> /// </summary>
public const int OpponentJudgeSpin = 100; public const int OpponentJudgeSpin = 100;
/// <summary>Spin value the PvP relay stamps on the Judge / OpponentTurnStart handover frames
/// in the deterministic-turn slice. 0 = no animation seed; per-turn spin is deferred
/// (see the real-spin design). The client self-generates its turn-open and doesn't read it.</summary>
public const int DeterministicTurnSpin = 0;
} }

View File

@@ -0,0 +1,41 @@
namespace SVSim.BattleNode.Lifecycle;
/// <summary>
/// Deterministic per-battle seed derivation. Given one random master seed (chosen once per battle
/// on <see cref="Sessions.Dispatch.BattleSessionState"/>), derives every RNG value the node hands
/// the clients: the shared effect seed (Matched.seed), each side's deck-shuffle RNG seed, and each
/// side's Ready.idxChangeSeed.
///
/// IMPORTANT: uses a fixed splitmix64-style bit-mix, NOT System.HashCode / string.GetHashCode
/// (those are randomized per process). Stability across process runs is what makes "same master
/// seed reproduces the same battle" — the foundation of replay — actually hold.
/// </summary>
internal static class BattleSeeds
{
/// <summary>Shared effect-RNG seed; identical for both sides (it seeds the synced stream).</summary>
public static int Stable(int master) => Derive(master, "stable");
/// <summary>Per-side Ready.idxChangeSeed (client XorShift for mid-battle card-into-deck).</summary>
public static int IdxChange(int master, long viewerId) => Derive(master, "idx", viewerId);
/// <summary>Per-side deck-shuffle RNG seed (node-side FisherYates).</summary>
public static int DeckShuffle(int master, long viewerId) => Derive(master, "deck", viewerId);
/// <summary>Derive a stable non-negative int from (master, tag, discriminator). Pure arithmetic
/// — reproducible across process runs and platforms.</summary>
public static int Derive(int master, string tag, long disc = 0)
{
ulong h = Mix((uint)master);
foreach (char c in tag) h = Mix(h ^ c);
h = Mix(h ^ (ulong)disc);
return (int)(h & 0x7FFFFFFFUL);
}
private static ulong Mix(ulong x)
{
x += 0x9E3779B97F4A7C15UL;
x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9UL;
x = (x ^ (x >> 27)) * 0x94D049BB133111EBUL;
return x ^ (x >> 31);
}
}

View File

@@ -23,7 +23,7 @@ public static class ServerBattleFrames
public static MsgEnvelope BuildMatched( public static MsgEnvelope BuildMatched(
MatchContext selfCtx, MatchContext oppoCtx, MatchContext selfCtx, MatchContext oppoCtx,
long selfViewerId, long oppoViewerId, long selfViewerId, long oppoViewerId,
string battleId, long seed) => string battleId, int seed, IReadOnlyList<long> selfDeckOrder) =>
EnvelopeForPush(NetworkBattleUri.Matched, EnvelopeForPush(NetworkBattleUri.Matched,
new MatchedBody( new MatchedBody(
SelfInfo: new MatchedSelfInfo( SelfInfo: new MatchedSelfInfo(
@@ -33,8 +33,8 @@ public static class ServerBattleFrames
EmblemId: selfCtx.EmblemId, EmblemId: selfCtx.EmblemId,
DegreeId: selfCtx.DegreeId, DegreeId: selfCtx.DegreeId,
FieldId: selfCtx.FieldId, FieldId: selfCtx.FieldId,
IsOfficial: selfCtx.IsOfficial, IsOfficial: selfCtx.IsOfficial != 0,
OppoId: oppoViewerId, OppoId: (int)oppoViewerId,
Seed: seed), Seed: seed),
OppoInfo: new MatchedOppoInfo( OppoInfo: new MatchedOppoInfo(
CountryCode: oppoCtx.CountryCode, CountryCode: oppoCtx.CountryCode,
@@ -43,23 +43,23 @@ public static class ServerBattleFrames
EmblemId: oppoCtx.EmblemId, EmblemId: oppoCtx.EmblemId,
DegreeId: oppoCtx.DegreeId, DegreeId: oppoCtx.DegreeId,
FieldId: oppoCtx.FieldId, FieldId: oppoCtx.FieldId,
IsOfficial: oppoCtx.IsOfficial, IsOfficial: oppoCtx.IsOfficial != 0,
OppoId: selfViewerId, OppoId: (int)selfViewerId,
Seed: seed, Seed: seed,
OppoDeckCount: oppoCtx.SelfDeckCardIds.Count), OppoDeckCount: oppoCtx.SelfDeckCardIds.Count),
SelfDeck: BuildPlayerDeck(selfCtx.SelfDeckCardIds)), SelfDeck: BuildPlayerDeck(selfDeckOrder)),
bid: battleId); bid: battleId);
public static MsgEnvelope BuildBattleStart( public static MsgEnvelope BuildBattleStart(
MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, int turnState) => MatchContext selfCtx, MatchContext oppoCtx, long selfViewerId, TurnState turnState) =>
EnvelopeForPush(NetworkBattleUri.BattleStart, EnvelopeForPush(NetworkBattleUri.BattleStart,
new BattleStartBody( new BattleStartBody(
TurnState: turnState, // 0 = this side goes first, 1 = second. Caller decides. TurnState: turnState, // First = this side goes first, Second = second. Caller decides.
BattleType: selfCtx.BattleType, BattleModeId: selfCtx.BattleModeId,
SelfInfo: new BattleStartSelfInfo( SelfInfo: new BattleStartSelfInfo(
Rank: BattleFrameDefaults.PlayerRank, Rank: BattleFrameDefaults.PlayerRank,
BattlePoint: BattleFrameDefaults.PlayerBattlePoint, BattlePoint: BattleFrameDefaults.PlayerBattlePoint,
ClassId: selfCtx.ClassId, ClassId: selfCtx.ClassId.ToWireValue(),
CharaId: selfCtx.CharaId, CharaId: selfCtx.CharaId,
CardMasterName: selfCtx.CardMasterName), CardMasterName: selfCtx.CardMasterName),
OppoInfo: new BattleStartOppoInfo( OppoInfo: new BattleStartOppoInfo(
@@ -69,7 +69,7 @@ public static class ServerBattleFrames
IsMasterRank: "0", IsMasterRank: "0",
BattlePoint: 0, BattlePoint: 0,
MasterPoint: "0", MasterPoint: "0",
ClassId: oppoCtx.ClassId, ClassId: oppoCtx.ClassId.ToWireValue(),
CharaId: oppoCtx.CharaId, CharaId: oppoCtx.CharaId,
CardMasterName: oppoCtx.CardMasterName))); CardMasterName: oppoCtx.CardMasterName)));
@@ -90,13 +90,14 @@ public static class ServerBattleFrames
/// <summary> /// <summary>
/// Compute the player's hand after a mulligan. For every idx in <paramref name="swapIndices"/> /// Compute the player's hand after a mulligan. For every idx in <paramref name="swapIndices"/>
/// that is currently in the hand, replace it with the next unused deck idx (starting at 4, /// that is currently in the hand, replace it with the next unused deck idx (the first idx past
/// since 1..3 were dealt). Positions of kept cards are preserved. /// the opening hand — <see cref="InitialHand"/> is 1-based and contiguous, so that's
/// <c>InitialHand.Length + 1</c>). Positions of kept cards are preserved.
/// </summary> /// </summary>
public static long[] ComputeHandAfterSwap(IReadOnlyList<long> swapIndices) public static long[] ComputeHandAfterSwap(IReadOnlyList<long> swapIndices)
{ {
var hand = InitialHand.ToArray(); var hand = InitialHand.ToArray();
var nextDeckIdx = 4L; var nextDeckIdx = (long)(InitialHand.Length + 1);
for (var pos = 0; pos < hand.Length; pos++) for (var pos = 0; pos < hand.Length; pos++)
{ {
if (swapIndices.Contains(hand[pos])) if (swapIndices.Contains(hand[pos]))
@@ -113,16 +114,17 @@ public static class ServerBattleFrames
/// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder /// <summary>Non-interactive opponent (Bot/AI): oppo is the placeholder
/// <see cref="InitialHand"/>.</summary> /// <see cref="InitialHand"/>.</summary>
public static MsgEnvelope BuildReady(IReadOnlyList<long> hand) => BuildReady(hand, InitialHand); public static MsgEnvelope BuildReady(IReadOnlyList<long> hand, int idxChangeSeed) =>
BuildReady(hand, InitialHand, idxChangeSeed);
/// <summary>Both hands known (the mulligan barrier supplies the opponent's /// <summary>Both hands known (the mulligan barrier supplies the opponent's
/// post-mulligan hand).</summary> /// post-mulligan hand).</summary>
public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand) => public static MsgEnvelope BuildReady(IReadOnlyList<long> selfHand, IReadOnlyList<long> oppoHand, int idxChangeSeed) =>
EnvelopeForPush(NetworkBattleUri.Ready, EnvelopeForPush(NetworkBattleUri.Ready,
new ReadyBody( new ReadyBody(
Self: BuildPosIdxList(selfHand), Self: BuildPosIdxList(selfHand),
Oppo: BuildPosIdxList(oppoHand), Oppo: BuildPosIdxList(oppoHand),
IdxChangeSeed: BattleFrameDefaults.ReadyIdxChangeSeed, IdxChangeSeed: idxChangeSeed,
Spin: BattleFrameDefaults.ReadySpin)); Spin: BattleFrameDefaults.ReadySpin));
private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand) private static IReadOnlyList<PosIdx> BuildPosIdxList(IReadOnlyList<long> hand)
@@ -150,7 +152,7 @@ public static class ServerBattleFrames
ViewerId: FakeOpponentViewerId, ViewerId: FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid, Uuid: WireConstants.ServerUuid,
Bid: bid, Bid: bid,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,

View File

@@ -2,6 +2,10 @@ using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Protocol.Bodies;
/// <summary>Gungnir keepalive push. <c>scs</c> = self connection status, <c>ocs</c> = opponent
/// connection status; both carry <see cref="WireConstants.OnlineStatus"/> ("ONLINE") in v1.
/// Intentionally has no <c>resultCode</c> — the client treats an absent resultCode on alive
/// frames as "no error" (the lone body without one).</summary>
public sealed record AlivePushBody( public sealed record AlivePushBody(
[property: JsonPropertyName("scs")] string Scs, [property: JsonPropertyName("scs")] string Scs,
[property: JsonPropertyName("ocs")] string Ocs) : IMsgBody; [property: JsonPropertyName("ocs")] string Ocs) : IMsgBody;

View File

@@ -6,4 +6,4 @@ public sealed record BattleFinishBody(
[property: JsonPropertyName("result")] [property: JsonPropertyName("result")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<BattleResult>))] [property: JsonConverter(typeof(JsonNumberEnumConverter<BattleResult>))]
BattleResult Result, BattleResult Result,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -3,11 +3,14 @@ using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record BattleStartBody( public sealed record BattleStartBody(
[property: JsonPropertyName("turnState")] int TurnState, [property: JsonPropertyName("turnState")]
[property: JsonPropertyName("battleType")] int BattleType, [property: JsonConverter(typeof(JsonNumberEnumConverter<TurnState>))] TurnState TurnState,
// Wire key stays "battleType" (the client's contract); the CLR name is BattleModeId so the
// project keeps one meaning of "BattleType" — the Sessions.BattleType enum (Pvp/Bot).
[property: JsonPropertyName("battleType")] int BattleModeId,
[property: JsonPropertyName("selfInfo")] BattleStartSelfInfo SelfInfo, [property: JsonPropertyName("selfInfo")] BattleStartSelfInfo SelfInfo,
[property: JsonPropertyName("oppoInfo")] BattleStartOppoInfo OppoInfo, [property: JsonPropertyName("oppoInfo")] BattleStartOppoInfo OppoInfo,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
public sealed record BattleStartSelfInfo( public sealed record BattleStartSelfInfo(
[property: JsonPropertyName("rank")] string Rank, [property: JsonPropertyName("rank")] string Rank,
@@ -18,6 +21,7 @@ public sealed record BattleStartSelfInfo(
// Note: BattlePoint is int on the wire here (not string as on self) — matches the // Note: BattlePoint is int on the wire here (not string as on self) — matches the
// captured prod frame at data_dumps/captures/battle-traffic_tk2_regular.ndjson. // captured prod frame at data_dumps/captures/battle-traffic_tk2_regular.ndjson.
// The string-self / int-oppo split is INTENTIONAL; do NOT unify the two for "consistency".
public sealed record BattleStartOppoInfo( public sealed record BattleStartOppoInfo(
[property: JsonPropertyName("rank")] string Rank, [property: JsonPropertyName("rank")] string Rank,
[property: JsonPropertyName("isMasterRank")] string IsMasterRank, [property: JsonPropertyName("isMasterRank")] string IsMasterRank,

View File

@@ -5,4 +5,4 @@ namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record DealBody( public sealed record DealBody(
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self, [property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo, [property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Protocol.Bodies;
/// <summary>Server-pushed Judge frame (turn-handover gate; reflected to the sender in PvP).
/// Same wire shape as <see cref="OpponentTurnStartBody"/> — kept distinct because they back
/// different frames/URIs.</summary>
public sealed record JudgeBody( public sealed record JudgeBody(
[property: JsonPropertyName("spin")] int Spin, [property: JsonPropertyName("spin")] int Spin,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -6,8 +6,10 @@ public sealed record MatchedBody(
[property: JsonPropertyName("selfInfo")] MatchedSelfInfo SelfInfo, [property: JsonPropertyName("selfInfo")] MatchedSelfInfo SelfInfo,
[property: JsonPropertyName("oppoInfo")] MatchedOppoInfo OppoInfo, [property: JsonPropertyName("oppoInfo")] MatchedOppoInfo OppoInfo,
[property: JsonPropertyName("selfDeck")] IReadOnlyList<DeckCardRef> SelfDeck, [property: JsonPropertyName("selfDeck")] IReadOnlyList<DeckCardRef> SelfDeck,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;
// Note: `country_code` is deliberately snake_case among camelCase siblings — that's what prod
// sends on this frame (verified against the TK2 capture). Do NOT "normalize" it to countryCode.
public sealed record MatchedSelfInfo( public sealed record MatchedSelfInfo(
[property: JsonPropertyName("country_code")] string CountryCode, [property: JsonPropertyName("country_code")] string CountryCode,
[property: JsonPropertyName("userName")] string UserName, [property: JsonPropertyName("userName")] string UserName,
@@ -15,9 +17,10 @@ public sealed record MatchedSelfInfo(
[property: JsonPropertyName("emblemId")] string EmblemId, [property: JsonPropertyName("emblemId")] string EmblemId,
[property: JsonPropertyName("degreeId")] string DegreeId, [property: JsonPropertyName("degreeId")] string DegreeId,
[property: JsonPropertyName("fieldId")] int FieldId, [property: JsonPropertyName("fieldId")] int FieldId,
[property: JsonPropertyName("isOfficial")] int IsOfficial, [property: JsonPropertyName("isOfficial")]
[property: JsonPropertyName("oppoId")] long OppoId, [property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
[property: JsonPropertyName("seed")] long Seed); [property: JsonPropertyName("oppoId")] int OppoId,
[property: JsonPropertyName("seed")] int Seed);
public sealed record MatchedOppoInfo( public sealed record MatchedOppoInfo(
[property: JsonPropertyName("country_code")] string CountryCode, [property: JsonPropertyName("country_code")] string CountryCode,
@@ -26,9 +29,10 @@ public sealed record MatchedOppoInfo(
[property: JsonPropertyName("emblemId")] string EmblemId, [property: JsonPropertyName("emblemId")] string EmblemId,
[property: JsonPropertyName("degreeId")] string DegreeId, [property: JsonPropertyName("degreeId")] string DegreeId,
[property: JsonPropertyName("fieldId")] int FieldId, [property: JsonPropertyName("fieldId")] int FieldId,
[property: JsonPropertyName("isOfficial")] int IsOfficial, [property: JsonPropertyName("isOfficial")]
[property: JsonPropertyName("oppoId")] long OppoId, [property: JsonConverter(typeof(NumericBoolJsonConverter))] bool IsOfficial,
[property: JsonPropertyName("seed")] long Seed, [property: JsonPropertyName("oppoId")] int OppoId,
[property: JsonPropertyName("seed")] int Seed,
[property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount); [property: JsonPropertyName("oppoDeckCount")] int OppoDeckCount);
public sealed record DeckCardRef( public sealed record DeckCardRef(

View File

@@ -2,6 +2,9 @@ using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Protocol.Bodies;
/// <summary>Server-pushed opponent-turn-open frame (relayed to the non-active player).
/// Same wire shape as <see cref="JudgeBody"/> — kept distinct because they back different
/// frames/URIs.</summary>
public sealed record OpponentTurnStartBody( public sealed record OpponentTurnStartBody(
[property: JsonPropertyName("spin")] int Spin, [property: JsonPropertyName("spin")] int Spin,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -24,7 +24,8 @@ public sealed record PlayActionsBroadcastBody(
/// until the chosen card is played — and passed through for a visible (open:1) board choice (§6, /// until the chosen card is played — and passed through for a visible (open:1) board choice (§6,
/// provisional pending live confirmation).</summary> /// provisional pending live confirmation).</summary>
public sealed record KeyActionEntry( public sealed record KeyActionEntry(
[property: JsonPropertyName("type")] int Type, [property: JsonPropertyName("type")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<KeyActionType>))] KeyActionType Type,
[property: JsonPropertyName("cardId")] long CardId, [property: JsonPropertyName("cardId")] long CardId,
[property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard); [property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard);
@@ -32,7 +33,8 @@ public sealed record KeyActionEntry(
/// Only emitted for the open:1 pass-through case (open:0 strips the whole <c>selectCard</c>).</summary> /// Only emitted for the open:1 pass-through case (open:0 strips the whole <c>selectCard</c>).</summary>
public sealed record SelectCardEntry( public sealed record SelectCardEntry(
[property: JsonPropertyName("cardId")] IReadOnlyList<long> CardId, [property: JsonPropertyName("cardId")] IReadOnlyList<long> CardId,
[property: JsonPropertyName("open")] int Open); [property: JsonPropertyName("open")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<ChoiceVisibility>))] ChoiceVisibility Open);
/// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's /// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's
/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master /// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
@@ -48,7 +50,8 @@ public sealed record KnownCardEntry(
/// verbatim — no perspective flip (bullet-3 audit F2).</summary> /// verbatim — no perspective flip (bullet-3 audit F2).</summary>
public sealed record OppoTargetEntry( public sealed record OppoTargetEntry(
[property: JsonPropertyName("targetIdx")] int TargetIdx, [property: JsonPropertyName("targetIdx")] int TargetIdx,
[property: JsonPropertyName("isSelf")] int IsSelf); [property: JsonPropertyName("isSelf")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<CardOwner>))] CardOwner IsSelf);
/// <summary>One entry in a relayed <c>uList</c> (the unapproved-movement list) — a skill-driven /// <summary>One entry in a relayed <c>uList</c> (the unapproved-movement list) — a skill-driven
/// card movement (fetch / search / summon-from-deck / discard-reveal) the node forwards VERBATIM /// card movement (fetch / search / summon-from-deck / discard-reveal) the node forwards VERBATIM
@@ -60,12 +63,14 @@ public sealed record UnapprovedCardEntry(
[property: JsonPropertyName("idxList")] IReadOnlyList<int> IdxList, [property: JsonPropertyName("idxList")] IReadOnlyList<int> IdxList,
[property: JsonPropertyName("from")] int From, [property: JsonPropertyName("from")] int From,
[property: JsonPropertyName("to")] int To, [property: JsonPropertyName("to")] int To,
[property: JsonPropertyName("isSelf")] int IsSelf, [property: JsonPropertyName("isSelf")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<CardOwner>))] CardOwner IsSelf,
[property: JsonPropertyName("skill")] string Skill, [property: JsonPropertyName("skill")] string Skill,
[property: JsonPropertyName("cardId")] long? CardId = null, [property: JsonPropertyName("cardId")] long? CardId = null,
[property: JsonPropertyName("clan")] int? Clan = null, [property: JsonPropertyName("clan")] int? Clan = null,
[property: JsonPropertyName("cost")] int? Cost = null, [property: JsonPropertyName("cost")] int? Cost = null,
[property: JsonPropertyName("skillKeyCardIdx")] IReadOnlyList<int>? SkillKeyCardIdx = null, [property: JsonPropertyName("skillKeyCardIdx")] IReadOnlyList<int>? SkillKeyCardIdx = null,
[property: JsonPropertyName("randomTargetIdx")] IReadOnlyList<int>? RandomTargetIdx = null, [property: JsonPropertyName("randomTargetIdx")] IReadOnlyList<int>? RandomTargetIdx = null,
[property: JsonPropertyName("isInvoke")] int? IsInvoke = null, [property: JsonPropertyName("isInvoke")]
[property: JsonConverter(typeof(NumericBoolJsonConverter))] bool? IsInvoke = null,
[property: JsonPropertyName("attachTarget")] string? AttachTarget = null); [property: JsonPropertyName("attachTarget")] string? AttachTarget = null);

View File

@@ -7,4 +7,4 @@ public sealed record ReadyBody(
[property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo, [property: JsonPropertyName("oppo")] IReadOnlyList<PosIdx> Oppo,
[property: JsonPropertyName("idxChangeSeed")] int IdxChangeSeed, [property: JsonPropertyName("idxChangeSeed")] int IdxChangeSeed,
[property: JsonPropertyName("spin")] int Spin, [property: JsonPropertyName("spin")] int Spin,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -3,4 +3,4 @@ using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record ResultCodeOnlyBody( public sealed record ResultCodeOnlyBody(
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -4,4 +4,4 @@ namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record SwapResponseBody( public sealed record SwapResponseBody(
[property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self, [property: JsonPropertyName("self")] IReadOnlyList<PosIdx> Self,
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -3,5 +3,6 @@ using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol.Bodies; namespace SVSim.BattleNode.Protocol.Bodies;
public sealed record TurnEndBody( public sealed record TurnEndBody(
[property: JsonPropertyName("turnState")] int TurnState, [property: JsonPropertyName("turnState")]
[property: JsonPropertyName("resultCode")] int ResultCode = 1) : IMsgBody; [property: JsonConverter(typeof(JsonNumberEnumConverter<TurnState>))] TurnState TurnState,
[property: JsonPropertyName("resultCode")] int ResultCode = (int)ReceiveNodeResultCode.Success) : IMsgBody;

View File

@@ -0,0 +1,17 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wire value of the actor-relative <c>isSelf</c> flag on relayed lists (<c>targetList</c>,
/// <c>uList</c>): whose side a referenced card belongs to, from the SENDER's perspective. The node
/// forwards it verbatim — no perspective flip (bullet-3 audit F2). The client reads it via
/// <c>ConvertToInt(...) == 1</c> (<c>NetworkBattleReceiver.cs</c>), so it serializes as the
/// underlying int via <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>.
/// </summary>
public enum CardOwner
{
/// <summary>Card belongs to the opponent of the sender.</summary>
Opponent = 0,
/// <summary>Card belongs to the sender.</summary>
Self = 1,
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wire value of <c>open</c> on a choice/Discover <c>selectCard</c>: whether the pick is revealed.
/// The client emits it as <c>selectCardIsOpen ? 1 : 0</c> (<c>SendKeyActionDataManager.cs</c>);
/// the node uses it to decide whether to strip the pick for the opponent (<c>Hidden</c> = strip).
/// Serializes as the underlying int via
/// <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>.
/// </summary>
public enum ChoiceVisibility
{
/// <summary>Hidden draw-to-hand pick — the chosen card stays secret until played.</summary>
Hidden = 0,
/// <summary>Visible board choice — the pick is revealed immediately.</summary>
Open = 1,
}

View File

@@ -0,0 +1,23 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wire value of <c>type</c> on a keyAction entry — what kind of card-generating choice the play
/// is. Mirrors the client's <c>SendKeyActionDataManager.KeyActionType</c> exactly (same ordinals);
/// the client reads it back via <c>ConvertToInt(...)</c>, so it serializes as the underlying int
/// via <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>. The node currently
/// relays only <see cref="Choice"/> and <see cref="HaveBeforeSkillChoice"/>
/// (<see cref="Bodies.KeyActionEntry"/> / <c>KnownListBuilder.StripKeyActionForOpponent</c>); the
/// rest are defined so the guard compares against named values instead of bare ints.
/// </summary>
public enum KeyActionType
{
None = 0,
Choice = 1,
Accelerated = 2,
Crystallize = 3,
Fusion = 4,
HaveBeforeSkillChoice = 5,
BurialRate = 6,
ChoiceEvolution = 7,
ChoiceBrave = 8,
}

View File

@@ -1,6 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using System.Text.Json.Serialization; using SVSim.BattleNode.Wire;
namespace SVSim.BattleNode.Protocol; namespace SVSim.BattleNode.Protocol;
@@ -14,32 +14,37 @@ public sealed record MsgEnvelope(
long ViewerId, long ViewerId,
string Uuid, string Uuid,
string? Bid, string? Bid,
int Try, int RetryAttempt,
EmitCategory Cat, EmitCategory Cat,
long? PubSeq, long? PubSeq,
long? PlaySeq, long? PlaySeq,
IMsgBody Body) IMsgBody Body)
{ {
private static readonly JsonSerializerOptions Options = CreateOptions(); // Bare-camelCase wire serialization, single-sourced in Wire.WireJsonOptions (shared with
// EngineIoHandshake). Every wire key here is explicit via the manual ToJson layering below.
private static readonly JsonSerializerOptions Options = WireJsonOptions.CamelCase;
/// <summary>The fixed envelope wire keys, single-sourced. <see cref="ReservedEnvelopeKeys"/>,
/// the <see cref="ToJson"/> writes, and the <see cref="FromJson"/> reads all draw from here, so
/// the three encodings can't drift — adding a key in one place but not another (which would let a
/// body key silently shadow an envelope field) is no longer possible.</summary>
private static class Keys
{
public const string Uri = "uri";
public const string ViewerId = "viewerId";
public const string Uuid = "uuid";
public const string Bid = "bid";
public const string Try = "try";
public const string Cat = "cat";
public const string PubSeq = "pubSeq";
public const string PlaySeq = "playSeq";
}
private static readonly HashSet<string> ReservedEnvelopeKeys = new() private static readonly HashSet<string> ReservedEnvelopeKeys = new()
{ {
"uri", "viewerId", "uuid", "bid", "try", "cat", "pubSeq", "playSeq", Keys.Uri, Keys.ViewerId, Keys.Uuid, Keys.Bid, Keys.Try, Keys.Cat, Keys.PubSeq, Keys.PlaySeq,
}; };
private static JsonSerializerOptions CreateOptions()
{
var opt = new JsonSerializerOptions
{
// Wire-key casing is bare camelCase via per-field [JsonPropertyName] —
// NOT EmulatedEntrypoint's snake_case policy. The naming-policy line
// that was here previously was dead code (every wire key is explicit).
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
opt.Converters.Add(new JsonStringEnumConverter());
return opt;
}
public static string ToJson(MsgEnvelope env) public static string ToJson(MsgEnvelope env)
{ {
// Envelope fields MUST come before body fields on the wire. The client's // Envelope fields MUST come before body fields on the wire. The client's
@@ -48,14 +53,14 @@ public sealed record MsgEnvelope(
// field processed before "uri" is wiped before Matching.StartBattleLoad reads // field processed before "uri" is wiped before Matching.StartBattleLoad reads
// it back. The prod wire emits envelope keys first; we must too. // it back. The prod wire emits envelope keys first; we must too.
var result = new JsonObject(); var result = new JsonObject();
result["uri"] = env.Uri.ToString(); result[Keys.Uri] = env.Uri.ToString();
result["viewerId"] = env.ViewerId; result[Keys.ViewerId] = env.ViewerId;
result["uuid"] = env.Uuid; result[Keys.Uuid] = env.Uuid;
result["try"] = env.Try; result[Keys.Try] = env.RetryAttempt;
result["cat"] = (int)env.Cat; result[Keys.Cat] = (int)env.Cat;
if (env.Bid is not null) result["bid"] = env.Bid; if (env.Bid is not null) result[Keys.Bid] = env.Bid;
if (env.PubSeq.HasValue) result["pubSeq"] = env.PubSeq.Value; if (env.PubSeq.HasValue) result[Keys.PubSeq] = env.PubSeq.Value;
if (env.PlaySeq.HasValue) result["playSeq"] = env.PlaySeq.Value; if (env.PlaySeq.HasValue) result[Keys.PlaySeq] = env.PlaySeq.Value;
if (env.Body is RawBody raw) if (env.Body is RawBody raw)
{ {
@@ -129,14 +134,14 @@ public sealed record MsgEnvelope(
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
var root = doc.RootElement; var root = doc.RootElement;
var uri = Enum.Parse<NetworkBattleUri>(root.GetProperty("uri").GetString()!); var uri = Enum.Parse<NetworkBattleUri>(root.GetProperty(Keys.Uri).GetString()!);
var viewerId = root.GetProperty("viewerId").GetInt64(); var viewerId = root.GetProperty(Keys.ViewerId).GetInt64();
var uuid = root.GetProperty("uuid").GetString()!; var uuid = root.GetProperty(Keys.Uuid).GetString()!;
var bid = root.TryGetProperty("bid", out var bidEl) ? bidEl.GetString() : null; var bid = root.TryGetProperty(Keys.Bid, out var bidEl) ? bidEl.GetString() : null;
var @try = root.TryGetProperty("try", out var tryEl) ? tryEl.GetInt32() : 0; var retryAttempt = root.TryGetProperty(Keys.Try, out var tryEl) ? tryEl.GetInt32() : 0;
var cat = root.TryGetProperty("cat", out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle; var cat = root.TryGetProperty(Keys.Cat, out var catEl) ? (EmitCategory)catEl.GetInt32() : EmitCategory.Battle;
var pubSeq = root.TryGetProperty("pubSeq", out var psEl) ? psEl.GetInt64() : (long?)null; var pubSeq = root.TryGetProperty(Keys.PubSeq, out var psEl) ? psEl.GetInt64() : (long?)null;
var playSeq = root.TryGetProperty("playSeq", out var plsEl) ? plsEl.GetInt64() : (long?)null; var playSeq = root.TryGetProperty(Keys.PlaySeq, out var plsEl) ? plsEl.GetInt64() : (long?)null;
var bodyDict = new Dictionary<string, object?>(); var bodyDict = new Dictionary<string, object?>();
foreach (var prop in root.EnumerateObject()) foreach (var prop in root.EnumerateObject())
@@ -145,7 +150,7 @@ public sealed record MsgEnvelope(
bodyDict[prop.Name] = ToObject(prop.Value); bodyDict[prop.Name] = ToObject(prop.Value);
} }
return new MsgEnvelope(uri, viewerId, uuid, bid, @try, cat, pubSeq, playSeq, new RawBody(bodyDict)); return new MsgEnvelope(uri, viewerId, uuid, bid, retryAttempt, cat, pubSeq, playSeq, new RawBody(bodyDict));
} }
private static object? ToObject(JsonElement el) => el.ValueKind switch private static object? ToObject(JsonElement el) => el.ValueKind switch

View File

@@ -0,0 +1,29 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Serializes a <see cref="bool"/> as the wire's numeric 0/1. The client reads these flags via
/// <c>Convert.ToInt32</c> / <c>Convert.ToBoolean</c> (e.g. <c>isOfficial</c>, <c>isInvoke</c>) —
/// never as a JSON <c>true</c>/<c>false</c> token — so a real <c>bool</c> property must still emit
/// a number. Read accepts a JSON number (0 = false, non-zero = true) and, defensively, a
/// <c>true</c>/<c>false</c> token or a numeric string. Applied per-field via
/// <c>[JsonConverter(typeof(NumericBoolJsonConverter))]</c>; works on <c>bool?</c> too (System.Text.Json
/// wraps a <c>JsonConverter&lt;bool&gt;</c> for the nullable case).
/// </summary>
public sealed class NumericBoolJsonConverter : JsonConverter<bool>
{
public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.TokenType switch
{
JsonTokenType.Number => reader.GetInt64() != 0,
JsonTokenType.True => true,
JsonTokenType.False => false,
JsonTokenType.String => long.TryParse(reader.GetString(), out var n) && n != 0,
_ => throw new JsonException($"Cannot convert token {reader.TokenType} to a numeric bool"),
};
public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value ? 1 : 0);
}

View File

@@ -0,0 +1,16 @@
namespace SVSim.BattleNode.Protocol;
/// <summary>
/// Wire value of <c>turnState</c> on BattleStart / TurnEnd frames: which side acts first.
/// The client reads it via <c>Convert.ToInt32</c> (<c>RealTimeNetworkAgent.cs</c> "turnState"
/// case) into <c>NetworkUserInfoData.TurnState</c>, so it serializes as the underlying int via
/// <see cref="System.Text.Json.Serialization.JsonNumberEnumConverter{T}"/>.
/// </summary>
public enum TurnState
{
/// <summary>This side takes the first turn.</summary>
First = 0,
/// <summary>This side takes the second turn.</summary>
Second = 1,
}

View File

@@ -1,8 +1,11 @@
namespace SVSim.BattleNode.Reliability; namespace SVSim.BattleNode.Reliability;
/// <summary> /// <summary>
/// Body builders for the alive channel. The timer/loop that drives 5s emits lives on /// Body builders for the alive channel ("Gungnir" is the client's codename for the
/// BattleSession; this class is just the pure body-shape factory. /// keepalive/connection-status channel — see <see cref="Protocol.Bodies.AlivePushBody"/>).
/// The timer/loop that would drive the emit cadence
/// (<see cref="Bridge.BattleNodeOptions.AliveEmitInterval"/>) is to live on BattleSession;
/// this class is just the pure body-shape factory.
/// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push /// v1 always reports scs/ocs=ONLINE — real disconnect detection is deferred. The push
/// body itself is constructed inline in BattleSession.HandleAliveEventAsync using /// body itself is constructed inline in BattleSession.HandleAliveEventAsync using
/// AlivePushBody; only the emit body (sent by us TO the client on the alive channel, /// AlivePushBody; only the emit body (sent by us TO the client on the alive channel,
@@ -10,8 +13,6 @@ namespace SVSim.BattleNode.Reliability;
/// </summary> /// </summary>
public static class Gungnir public static class Gungnir
{ {
public static readonly TimeSpan EmitInterval = TimeSpan.FromSeconds(5);
public static Dictionary<string, object?> BuildAliveEmitBody(InboundTracker tracker) => new() public static Dictionary<string, object?> BuildAliveEmitBody(InboundTracker tracker) => new()
{ {
["currentSeq"] = tracker.HighWaterMark, ["currentSeq"] = tracker.HighWaterMark,

View File

@@ -9,7 +9,16 @@ namespace SVSim.BattleNode.Reliability;
/// </summary> /// </summary>
public sealed class OutboundSequencer public sealed class OutboundSequencer
{ {
private long _next = 1; /// <summary>First playSeq assigned. Starts at 1, not 0 — 0 is reserved for no-stock /
/// unsequenced pushes (which carry a null PlaySeq via <see cref="WrapNoStock"/>).</summary>
private const long FirstPlaySeq = 1;
private long _next = FirstPlaySeq;
// Holds every ordered (stocked) push for the WHOLE match — there is no per-ack pruning, so it
// grows with battle length × concurrent battles. Bounded only by Clear() in the terminate cascade.
// Fine at current scale; if battles get long or concurrency scales, prune entries below the peer's
// ack watermark here (contrast the inbound side, which is bounded by InboundTracker.WindowSize).
private readonly Dictionary<long, MsgEnvelope> _archive = new(); private readonly Dictionary<long, MsgEnvelope> _archive = new();
public IReadOnlyDictionary<long, MsgEnvelope> Archive => _archive; public IReadOnlyDictionary<long, MsgEnvelope> Archive => _archive;

View File

@@ -1,3 +1,4 @@
using System.Net.WebSockets;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Dispatch;
@@ -9,7 +10,7 @@ namespace SVSim.BattleNode.Sessions;
/// <summary> /// <summary>
/// v2 broker session. Holds two participants and brokers between them. Subscribes /// v2 broker session. Holds two participants and brokers between them. Subscribes
/// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame, /// to each participant's <see cref="IBattleParticipant.FrameEmitted"/>; on each frame,
/// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + noStock /// runs <see cref="ComputeFrames"/> to determine the routing (target + frame + <see cref="Stock"/>
/// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>. /// flag) and dispatches via <see cref="IBattleParticipant.PushAsync"/>.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
@@ -22,11 +23,22 @@ public sealed class BattleSession
private readonly BattleSessionState _state = new(); private readonly BattleSessionState _state = new();
/// <summary>Serializes dispatch. Both participants' read loops raise FrameEmitted on their own
/// threads, and a dispatch (<see cref="ComputeFrames"/> + the relay <c>PushAsync</c> calls) mutates
/// shared, non-thread-safe state — the <see cref="BattleSessionState"/> dictionaries and each
/// participant's <c>OutboundSequencer</c>. This gate funnels both threads through one critical
/// section so concurrent frames can't corrupt that state.</summary>
private readonly SemaphoreSlim _dispatchGate = new(1, 1);
/// <summary>The per-battle master seed (see <see cref="BattleSessionState.MasterSeed"/>).
/// Exposed for logging + future replay persistence.</summary>
public int MasterSeed => _state.MasterSeed;
public string BattleId { get; } public string BattleId { get; }
public BattleType Type { get; } public BattleType Type { get; }
public IBattleParticipant A { get; } public IBattleParticipant A { get; }
public IBattleParticipant B { get; } public IBattleParticipant B { get; }
public BattleSessionPhase Phase => _state.SessionPhase; public SessionLifecycle Lifecycle => _state.Lifecycle;
// Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown // Per-URI dispatch table. All 14 inbound URIs are registered (Tasks 5-14); unknown
// URIs are dropped with a LogDebug in ComputeFrames. // URIs are dropped with a LogDebug in ComputeFrames.
@@ -59,7 +71,7 @@ public sealed class BattleSession
new() new()
{ {
A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A, A = A, B = B, From = from, Other = ReferenceEquals(from, A) ? B : A,
Env = env, Type = Type, BattleId = BattleId, State = _state, Env = env, BattleId = BattleId, State = _state,
}; };
public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b, public BattleSession(string battleId, BattleType type, IBattleParticipant a, IBattleParticipant b,
@@ -71,6 +83,8 @@ public sealed class BattleSession
B = b; B = b;
_log = log; _log = log;
_log.LogInformation("BattleSession {Bid}: master seed {Seed}", BattleId, _state.MasterSeed);
// Subscribe to both participants' emissions. // Subscribe to both participants' emissions.
A.FrameEmitted += OnFrameFromA; A.FrameEmitted += OnFrameFromA;
B.FrameEmitted += OnFrameFromB; B.FrameEmitted += OnFrameFromB;
@@ -90,7 +104,7 @@ public sealed class BattleSession
var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false); var first = await Task.WhenAny(aTask, bTask).ConfigureAwait(false);
var survivor = first == aTask ? B : A; var survivor = first == aTask ? B : A;
if (Phase != BattleSessionPhase.Terminal) if (Lifecycle != SessionLifecycle.Terminal)
{ {
// Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin) // Involuntary drop (no graceful Retire): synthesize BattleFinish(DisconnectWin)
// to survivor. DisconnectWin=201 → client renders "opponent disconnected" → // to survivor. DisconnectWin=201 → client renders "opponent disconnected" →
@@ -98,7 +112,7 @@ public sealed class BattleSession
try try
{ {
await survivor.PushAsync( await survivor.PushAsync(
BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation) BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), Stock.Bypass, cancellation)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -107,25 +121,39 @@ public sealed class BattleSession
"BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)", "BattleSession {Bid}: failed to push BattleFinish to survivor (their WS may also be closed)",
BattleId); BattleId);
} }
_state.SessionPhase = BattleSessionPhase.Terminal; _state.Lifecycle = SessionLifecycle.Terminal;
} }
cts.Cancel(); // unblock the survivor's RunAsync read loop cts.Cancel(); // unblock the survivor's RunAsync read loop
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); } try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow cancellation / WS exceptions */ } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { }
catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All(
e => e is OperationCanceledException or WebSocketException)) { }
catch (Exception ex)
{
_log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (PvP drain)", BattleId);
}
} }
else else
{ {
// Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real // Bot mode: the NoOp opponent's RunAsync returns immediately; wait for the real
// participant. The session keeps running for the real one. // participant. The session keeps running for the real one.
try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); } try { await Task.WhenAll(aTask, bTask).ConfigureAwait(false); }
catch { /* swallow */ } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) { }
catch (AggregateException ex) when (ex.Flatten().InnerExceptions.All(
e => e is OperationCanceledException or WebSocketException)) { }
catch (Exception ex)
{
_log.LogWarning(ex, "BattleSession {Bid}: unexpected exception from WhenAll (Bot drain)", BattleId);
}
} }
// Audit Md11 — release per-participant outbound archives at battle-end // Unsubscribe event handlers so the session + state aren't pinned by live delegates.
// (only RealParticipant has one; bots don't archive). Heavy state is A.FrameEmitted -= OnFrameFromA;
// dropped synchronously here so the participant's TerminateAsync doesn't B.FrameEmitted -= OnFrameFromB;
// need to keep the dict alive through its disposal handshake.
// Release per-participant outbound archives at battle-end
// (only RealParticipant has one; bots don't archive).
if (A is RealParticipant rpA) rpA.Outbound.Clear(); if (A is RealParticipant rpA) rpA.Outbound.Clear();
if (B is RealParticipant rpB) rpB.Outbound.Clear(); if (B is RealParticipant rpB) rpB.Outbound.Clear();
@@ -133,6 +161,10 @@ public sealed class BattleSession
A.TerminateAsync(BattleFinishReason.NormalFinish), A.TerminateAsync(BattleFinishReason.NormalFinish),
B.TerminateAsync(BattleFinishReason.NormalFinish)) B.TerminateAsync(BattleFinishReason.NormalFinish))
.ConfigureAwait(false); .ConfigureAwait(false);
await A.DisposeAsync().ConfigureAwait(false);
await B.DisposeAsync().ConfigureAwait(false);
_dispatchGate.Dispose();
} }
private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct); private Task OnFrameFromA(MsgEnvelope env, CancellationToken ct) => HandleFrameAsync(A, env, ct);
@@ -140,23 +172,28 @@ public sealed class BattleSession
private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct) private async Task HandleFrameAsync(IBattleParticipant from, MsgEnvelope env, CancellationToken ct)
{ {
await _dispatchGate.WaitAsync(ct).ConfigureAwait(false);
try try
{ {
var routes = ComputeFrames(from, env); var routes = ComputeFrames(from, env);
foreach (var (target, frame, noStock) in routes) foreach (var (target, frame, stock) in routes)
{ {
await target.PushAsync(frame, noStock, ct); await target.PushAsync(frame, stock, ct);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
_log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId); _log.LogError(ex, "BattleSession {Bid}: unhandled in HandleFrameAsync", BattleId);
} }
finally
{
_dispatchGate.Release();
}
} }
/// <summary> /// <summary>
/// Pure-logic dispatch: given an inbound frame from one participant, return the list /// Pure-logic dispatch: given an inbound frame from one participant, return the list
/// of (target, frame, noStock) tuples the session should dispatch. Transitions /// of (target, frame, stock) routes the session should dispatch. Transitions
/// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without /// <see cref="Phase"/>. Extracted so unit tests can drive the dispatch without
/// standing up real participants. /// standing up real participants.
/// </summary> /// </summary>
@@ -165,8 +202,8 @@ public sealed class BattleSession
if (Handlers.TryGetValue(env.Uri, out var handler)) if (Handlers.TryGetValue(env.Uri, out var handler))
return handler.Handle(BuildContext(from, env)); return handler.Handle(BuildContext(from, env));
_log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in phase={Phase} from vid={Vid}", _log.LogDebug("BattleSession {Bid}: dropping uri={Uri} in lifecycle={Lifecycle} from vid={Vid}",
BattleId, env.Uri, Phase, from.ViewerId); BattleId, env.Uri, Lifecycle, from.ViewerId);
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
} }

View File

@@ -1,16 +0,0 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Where we are in the v1 server-authored frame lifecycle. Drives which server-authored frames
/// the session pushes in response to inbound emits.
/// </summary>
public enum BattleSessionPhase
{
AwaitingInitNetwork,
AwaitingInitBattle,
AwaitingLoaded,
AwaitingSwap,
AfterReady,
OpponentTurn,
Terminal,
}

View File

@@ -13,7 +13,7 @@ internal static class BattleFrames
ViewerId: ServerBattleFrames.FakeOpponentViewerId, ViewerId: ServerBattleFrames.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid, Uuid: WireConstants.ServerUuid,
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.General, Cat: EmitCategory.General,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
@@ -24,18 +24,18 @@ internal static class BattleFrames
ViewerId: ServerBattleFrames.FakeOpponentViewerId, ViewerId: ServerBattleFrames.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid, Uuid: WireConstants.ServerUuid,
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
Body: new TurnEndBody(TurnState: 0)); Body: new TurnEndBody(TurnState: TurnState.First));
internal static MsgEnvelope BuildJudgeBroadcast() => new( internal static MsgEnvelope BuildJudgeBroadcast() => new(
NetworkBattleUri.Judge, NetworkBattleUri.Judge,
ViewerId: ServerBattleFrames.FakeOpponentViewerId, ViewerId: ServerBattleFrames.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid, Uuid: WireConstants.ServerUuid,
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
@@ -46,7 +46,7 @@ internal static class BattleFrames
ViewerId: ServerBattleFrames.FakeOpponentViewerId, ViewerId: ServerBattleFrames.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid, Uuid: WireConstants.ServerUuid,
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
@@ -55,7 +55,7 @@ internal static class BattleFrames
internal static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env) internal static IReadOnlyList<long> ExtractIdxList(MsgEnvelope env)
{ {
if (env.Body is not RawBody rawBody) return Array.Empty<long>(); if (env.Body is not RawBody rawBody) return Array.Empty<long>();
if (rawBody.Entries.TryGetValue("idxList", out var raw) && raw is System.Collections.IEnumerable seq && raw is not string) if (rawBody.Entries.TryGetValue(WireKeys.IdxList, out var raw) && raw is System.Collections.IEnumerable seq && raw is not string)
{ {
var result = new List<long>(); var result = new List<long>();
foreach (var item in seq) foreach (var item in seq)

View File

@@ -1,3 +1,5 @@
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Sessions; using SVSim.BattleNode.Sessions;
namespace SVSim.BattleNode.Sessions.Dispatch; namespace SVSim.BattleNode.Sessions.Dispatch;
@@ -9,7 +11,35 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
/// <see cref="IdxToCardId"/> map via <see cref="RecordToken"/>; a reveal-gate set is still future.</summary> /// <see cref="IdxToCardId"/> map via <see cref="RecordToken"/>; a reveal-gate set is still future.</summary>
internal sealed class BattleSessionState internal sealed class BattleSessionState
{ {
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; /// <summary>The one random value chosen per battle. Every per-battle RNG (shared effect seed,
/// each side's deck shuffle + idxChangeSeed) derives from it via <see cref="BattleSeeds"/>.
/// Logged at session start so a battle's randomness is reproducible (future replay).</summary>
public int MasterSeed { get; }
/// <param name="masterSeed">Test hook — production uses the random default.</param>
public BattleSessionState(int? masterSeed = null) =>
MasterSeed = masterSeed ?? Random.Shared.Next();
private readonly Dictionary<IBattleParticipant, IReadOnlyList<long>> _shuffledDecks = new();
/// <summary>This side's deck, shuffled deterministically from <see cref="MasterSeed"/>
/// (FisherYates). Cached per side. Both the wire selfDeck (Matched) and the reveal map
/// (<see cref="GetOrSeedDeckMap"/>) read this, so they share one shuffled order.</summary>
public IReadOnlyList<long> GetShuffledDeck(IBattleParticipant side)
{
if (_shuffledDecks.TryGetValue(side, out var cached)) return cached;
var deck = side.Context.SelfDeckCardIds.ToArray();
var rng = new Random(BattleSeeds.DeckShuffle(MasterSeed, side.ViewerId));
for (var i = deck.Length - 1; i > 0; i--)
{
var j = rng.Next(i + 1);
(deck[i], deck[j]) = (deck[j], deck[i]);
}
_shuffledDecks[side] = deck;
return deck;
}
public SessionLifecycle Lifecycle { get; set; } = SessionLifecycle.Active;
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new(); public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>. /// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
@@ -17,14 +47,15 @@ internal sealed class BattleSessionState
/// from add ops via <see cref="RecordToken"/>).</summary> /// from add ops via <see cref="RecordToken"/>).</summary>
public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new(); public Dictionary<IBattleParticipant, Dictionary<int, long>> IdxToCardId { get; } = new();
/// <summary>The sender's idx->cardId map, seeding it from its <see cref="MatchContext"/> on first /// <summary>The sender's idx->cardId map, seeding it from its <see cref="GetShuffledDeck"/> order on
/// use. <c>BuildPlayerDeck</c> assigns deck idx = position+1, so entry (i+1) -> cardIds[i].</summary> /// first use. Deck idx = position+1 in the shuffled order, so entry (i+1) -> shuffledDeck[i]. The
/// wire selfDeck (Matched) is built from the same shuffled order, so the two agree.</summary>
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side) public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
{ {
if (!IdxToCardId.TryGetValue(side, out var map)) if (!IdxToCardId.TryGetValue(side, out var map))
{ {
map = new Dictionary<int, long>(); map = new Dictionary<int, long>();
var deck = side.Context.SelfDeckCardIds; var deck = GetShuffledDeck(side);
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i]; for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
IdxToCardId[side] = map; IdxToCardId[side] = map;
} }
@@ -53,8 +84,13 @@ internal sealed class BattleSessionState
/// Echo is mined but never relayed.</summary> /// Echo is mined but never relayed.</summary>
public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList) public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
{ {
// TRUST: isSelf is the SENDER's own perspective flag and idx is unbounded, while RecordToken
// overwrites-on-conflict. A buggy/malicious sender could pass isSelf:0 with a deck-range idx to
// rewrite the OPPONENT's card identity at a seeded slot. Acceptable for the current trusted-LAN
// relay; if peers ever become untrusted, gate on `idx > deckCount` here (generated tokens always
// allocate past the deck) so a sender can't forge over seeded deck cards.
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList)) foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
RecordToken(isSelf == 1 ? from : other, idx, cardId); RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
} }
/// <summary>Mine + record choice/Discover-token picks (<see cref="KnownListBuilder.MineChoicePicks"/>) /// <summary>Mine + record choice/Discover-token picks (<see cref="KnownListBuilder.MineChoicePicks"/>)
@@ -65,7 +101,7 @@ internal sealed class BattleSessionState
public void RecordChoicePicksFrom(IBattleParticipant from, IBattleParticipant other, object? orderList, object? keyAction) public void RecordChoicePicksFrom(IBattleParticipant from, IBattleParticipant other, object? orderList, object? keyAction)
{ {
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction)) foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction))
RecordToken(isSelf == 1 ? from : other, idx, cardId); RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
} }
/// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>) /// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>)
@@ -82,6 +118,6 @@ internal sealed class BattleSessionState
var selfMap = GetOrSeedDeckMap(from); var selfMap = GetOrSeedDeckMap(from);
var otherMap = GetOrSeedDeckMap(other); var otherMap = GetOrSeedDeckMap(other);
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap)) foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap))
RecordToken(isSelf == 1 ? from : other, idx, cardId); RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
} }
} }

View File

@@ -3,6 +3,7 @@ using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Sessions.Dispatch; namespace SVSim.BattleNode.Sessions.Dispatch;
/// <summary>One routing decision: deliver <paramref name="Frame"/> to <paramref name="Target"/>. /// <summary>One routing decision: deliver <paramref name="Frame"/> to <paramref name="Target"/>.
/// Named form of the tuple <c>ComputeFrames</c> historically returned. <paramref name="NoStock"/> /// Named form of the tuple <c>ComputeFrames</c> historically returned. <paramref name="Stock"/>
/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive.</summary> /// is <see cref="Sessions.Stock.Bypass"/> for control frames (BattleFinish, ack) — bypasses
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock); /// playSeq assignment + archive — and <see cref="Sessions.Stock.Normal"/> for gameplay frames.</summary>
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, Stock Stock);

View File

@@ -14,21 +14,38 @@ internal sealed class FrameDispatchContext
internal required IBattleParticipant From { get; init; } internal required IBattleParticipant From { get; init; }
internal required IBattleParticipant Other { get; init; } internal required IBattleParticipant Other { get; init; }
internal required MsgEnvelope Env { get; init; } internal required MsgEnvelope Env { get; init; }
internal required BattleType Type { get; init; }
internal required string BattleId { get; init; } internal required string BattleId { get; init; }
internal required BattleSessionState State { get; init; } internal required BattleSessionState State { get; init; }
/// <summary>The opponent is an AI-passive (ack-only) bot: it runs no handshake — no
/// <see cref="IHasHandshakePhase"/> — and receives no relayed frames (the client drives its own
/// AI; the server only acks). This is the participant property that replaces the per-handler
/// <c>BattleType.Bot</c> switch: the Bot dispatch arms gate on it. Its inverse — a live relay
/// peer — is what <see cref="BothSidesAfterReady"/> already implies (only real peers have a
/// handshake phase), so the relay arms need no separate opponent check.</summary>
internal bool OpponentIsAckOnly => Other is not IHasHandshakePhase;
/// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase /// <summary>The dispatching participant's handshake phase (null for a non-IHasHandshakePhase
/// participant, e.g. NoOpBot). Setting it advances the sender.</summary> /// participant, e.g. NoOpBot). Setting it advances the sender.</summary>
internal BattleSessionPhase? SenderPhase internal HandshakePhase? SenderPhase
{ {
get => (From as IHasHandshakePhase)?.Phase; get => (From as IHasHandshakePhase)?.Phase;
set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; } set { if (From is IHasHandshakePhase p && value is { } v) p.Phase = v; }
} }
/// <summary>Both participants have completed the handshake. Reads A/B (not From/Other) so the /// <summary>Just the SENDER has finished the handshake — says nothing about the opponent. The
/// result is identical regardless of which side sent the frame — matches legacy BothAfterReady.</summary> /// Bot arms gate on this (the bot has no handshake phase of its own); contrast
internal bool BothAfterReady() => /// <see cref="BothSidesAfterReady"/>, which the PvP arms require. The sender-only vs both-sides
(A as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady && /// distinction is load-bearing for the Bot/PvP split (see TurnEndHandler / TurnEndFinalHandler).</summary>
(B as IHasHandshakePhase)?.Phase == BattleSessionPhase.AfterReady; internal bool SenderIsAfterReady => SenderPhase == HandshakePhase.AfterReady;
/// <summary>BOTH participants have finished the handshake. Reads A/B (not From/Other) so the
/// result is identical regardless of which side sent the frame. Contrast
/// <see cref="SenderIsAfterReady"/> (sender only). Only a live relay peer (real player) has a
/// handshake phase, so this can only be true in a two-real-player (PvP) session — the relay
/// dispatch arms gate on this instead of a <c>BattleType</c> check (an ack-only bot opponent,
/// <see cref="OpponentIsAckOnly"/>, can never satisfy it).</summary>
internal bool BothSidesAfterReady() =>
(A as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady &&
(B as IHasHandshakePhase)?.Phase == HandshakePhase.AfterReady;
} }

View File

@@ -13,9 +13,9 @@ internal sealed class EchoHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList"); var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault(WireKeys.OrderList);
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList); ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
// Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map. // Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map.
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList); ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);

View File

@@ -6,8 +6,8 @@ internal sealed class ForwardWhenBothReadyHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.BothAfterReady()) if (ctx.BothSidesAfterReady())
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) }; return new[] { new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal) };
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
} }
} }

View File

@@ -8,26 +8,27 @@ internal sealed class InitBattleHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
// case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info). // case 2: Bot — ack only, NO Matched (Matched would corrupt client opponent info).
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle) if (ctx.OpponentIsAckOnly && ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
{ {
var r = new List<DispatchRoute> var r = new List<DispatchRoute>
{ {
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), true), new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), Stock.Bypass),
}; };
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded; ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
return r; return r;
} }
// case 5: general — push Matched (per-perspective) to the sender only. // case 5: general — push Matched (per-perspective) to the sender only.
if (ctx.SenderPhase == BattleSessionPhase.AwaitingInitBattle) if (ctx.SenderPhase == HandshakePhase.AwaitingInitBattle)
{ {
var r = new List<DispatchRoute> var r = new List<DispatchRoute>
{ {
new(ctx.From, ServerBattleFrames.BuildMatched( new(ctx.From, ServerBattleFrames.BuildMatched(
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId, ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId,
ctx.BattleId, BattleFrameDefaults.BattleSeed), false), ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed),
ctx.State.GetShuffledDeck(ctx.From)), Stock.Normal),
}; };
ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded; ctx.SenderPhase = HandshakePhase.AwaitingLoaded;
return r; return r;
} }

View File

@@ -7,14 +7,14 @@ internal sealed class InitNetworkHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.SenderPhase != BattleSessionPhase.AwaitingInitNetwork) if (ctx.SenderPhase != HandshakePhase.AwaitingInitNetwork)
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
var routes = new List<DispatchRoute> var routes = new List<DispatchRoute>
{ {
new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), true), new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), Stock.Bypass),
}; };
ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle; ctx.SenderPhase = HandshakePhase.AwaitingInitBattle;
return routes; return routes;
} }
} }

View File

@@ -1,3 +1,4 @@
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Protocol.Bodies;
@@ -14,10 +15,10 @@ internal sealed class JudgeHandler : IFrameHandler
// start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture). // start another one, stalling the loop; confirmed by the 2026-06-03 two-client capture).
// The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}. // The sender then emits TurnStart, which TurnStartHandler relays to the opponent as {spin}.
// battleCode is dropped; spin=0 for the deterministic-turn slice. // battleCode is dropped; spin=0 for the deterministic-turn slice.
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) }; var frame = ctx.Env with { Body = new JudgeBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) };
return new[] { new DispatchRoute(ctx.From, frame, false) }; return new[] { new DispatchRoute(ctx.From, frame, Stock.Normal) };
} }
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();

View File

@@ -8,24 +8,24 @@ internal sealed class LoadedHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
// case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data). // case 3: Bot — silent (client populates opponent state from AIBattleStart HTTP data).
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded) if (ctx.OpponentIsAckOnly && ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
{ {
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap; ctx.SenderPhase = HandshakePhase.AwaitingSwap;
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
} }
// case 6: general — BattleStart (per-perspective) + Deal to the sender. // case 6: general — BattleStart (per-perspective) + Deal to the sender.
if (ctx.SenderPhase == BattleSessionPhase.AwaitingLoaded) if (ctx.SenderPhase == HandshakePhase.AwaitingLoaded)
{ {
// A goes first deterministically (turnState 0); B goes second (turnState 1). // A goes first deterministically; B goes second.
var turnState = ReferenceEquals(ctx.From, ctx.A) ? 0 : 1; var turnState = ReferenceEquals(ctx.From, ctx.A) ? TurnState.First : TurnState.Second;
var r = new List<DispatchRoute> var r = new List<DispatchRoute>
{ {
new(ctx.From, ServerBattleFrames.BuildBattleStart( new(ctx.From, ServerBattleFrames.BuildBattleStart(
ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), false), ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), Stock.Normal),
new(ctx.From, ServerBattleFrames.BuildDeal(), false), new(ctx.From, ServerBattleFrames.BuildDeal(), Stock.Normal),
}; };
ctx.SenderPhase = BattleSessionPhase.AwaitingSwap; ctx.SenderPhase = HandshakePhase.AwaitingSwap;
return r; return r;
} }

View File

@@ -14,15 +14,15 @@ internal sealed class PlayActionsHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady()) if (!ctx.BothSidesAfterReady())
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>(); var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx")); var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.PlayIdx));
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type")); var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.Type));
var orderList = entries.GetValueOrDefault("orderList"); var orderList = entries.GetValueOrDefault(WireKeys.OrderList);
var keyAction = entries.GetValueOrDefault("keyAction"); var keyAction = entries.GetValueOrDefault(WireKeys.KeyAction);
// Mine generated-token identities from this frame's add ops into the right side's idx->cardId // Mine generated-token identities from this frame's add ops into the right side's idx->cardId
// map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER // map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER
@@ -40,13 +40,13 @@ internal sealed class PlayActionsHandler : IFrameHandler
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From); var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList); var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList")); var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault(WireKeys.TargetList));
// Deck-sourced movements (fetch / search / summon-from-deck) ride the uList — a verbatim, // Deck-sourced movements (fetch / search / summon-from-deck) ride the uList — a verbatim,
// separate receive slot the node forwards unchanged (bullet-3 audit F1). The node makes no // separate receive slot the node forwards unchanged (bullet-3 audit F1). The node makes no
// reveal decision; cardId presence is the sender's call. Coexists with the synthesized // reveal decision; cardId presence is the sender's call. Coexists with the synthesized
// knownList in the same frame (capture line 75). // knownList in the same frame (capture line 75).
var uList = KnownListBuilder.RelayUList(entries.GetValueOrDefault("uList")); var uList = KnownListBuilder.RelayUList(entries.GetValueOrDefault(WireKeys.UList));
var body = new PlayActionsBroadcastBody( var body = new PlayActionsBroadcastBody(
PlayIdx: playIdx, PlayIdx: playIdx,
@@ -59,6 +59,6 @@ internal sealed class PlayActionsHandler : IFrameHandler
KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction)); KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction));
var frame = ctx.Env with { Body = body }; var frame = ctx.Env with { Body = body };
return new[] { new DispatchRoute(ctx.Other, frame, false) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
} }
} }

View File

@@ -6,11 +6,14 @@ internal sealed class RetireKillHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
ctx.State.SessionPhase = BattleSessionPhase.Terminal; ctx.State.Lifecycle = SessionLifecycle.Terminal;
// Polarity: the SENDER retired, so From LOSES / Other WINS. This is the OPPOSITE of
// TurnEndFinalHandler (From WINS there — sender dealt the lethal). Intentional — do NOT
// "consistency-fix" the two handlers to match; a swap here silently reverses every retire.
return new[] return new[]
{ {
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true), new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), Stock.Bypass),
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true), new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), Stock.Bypass),
}; };
} }
} }

View File

@@ -8,16 +8,16 @@ internal sealed class SwapHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.SenderPhase != BattleSessionPhase.AwaitingSwap) if (ctx.SenderPhase != HandshakePhase.AwaitingSwap)
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
var routes = new List<DispatchRoute>(); var routes = new List<DispatchRoute>();
var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env)); var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env));
// SwapResponse is always immediate — completes the sender's own mulligan UI. // SwapResponse is always immediate — completes the sender's own mulligan UI.
routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), false)); routes.Add(new DispatchRoute(ctx.From, ServerBattleFrames.BuildSwapResponse(hand), Stock.Normal));
ctx.State.PostSwapHands[ctx.From] = hand; ctx.State.PostSwapHands[ctx.From] = hand;
ctx.SenderPhase = BattleSessionPhase.AfterReady; ctx.SenderPhase = HandshakePhase.AfterReady;
// Release Ready to every swapper once all handshake-driving participants have swapped. // Release Ready to every swapper once all handshake-driving participants have swapped.
// IHasHandshakePhase membership IS the "participates in mulligan" set. // IHasHandshakePhase membership IS the "participates in mulligan" set.
@@ -27,11 +27,12 @@ internal sealed class SwapHandler : IFrameHandler
foreach (var p in swappers) foreach (var p in swappers)
{ {
var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A; var opponent = ReferenceEquals(p, ctx.A) ? ctx.B : ctx.A;
var idxSeed = BattleSeeds.IdxChange(ctx.State.MasterSeed, p.ViewerId);
var ready = opponent is IHasHandshakePhase var ready = opponent is IHasHandshakePhase
&& ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand) && ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand)
? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand) ? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand, idxSeed)
: ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p]); : ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], idxSeed);
routes.Add(new DispatchRoute(p, ready, false)); routes.Add(new DispatchRoute(p, ready, Stock.Normal));
} }
} }
return routes; return routes;

View File

@@ -9,10 +9,10 @@ internal sealed class TurnEndActionsHandler : IFrameHandler
{ {
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) }; var frame = ctx.Env with { Body = new RawBody(new Dictionary<string, object?>()) };
return new[] { new DispatchRoute(ctx.Other, frame, false) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
} }
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();
} }

View File

@@ -7,18 +7,22 @@ internal sealed class TurnEndFinalHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
// case 4: Bot — Judge to sender only. // case 4: Bot — Judge to sender only.
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady) if (ctx.OpponentIsAckOnly && ctx.SenderIsAfterReady)
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) }; return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
// case 9: general — forward the envelope to other + paired BattleFinish + Terminal. // case 9: general — forward the envelope to other + paired BattleFinish + Terminal.
if (ctx.SenderPhase == BattleSessionPhase.AfterReady) if (ctx.SenderIsAfterReady)
{ {
ctx.State.SessionPhase = BattleSessionPhase.Terminal; ctx.State.Lifecycle = SessionLifecycle.Terminal;
// Polarity: the SENDER dealt the lethal, so From WINS / Other LOSES. This is the
// OPPOSITE of RetireKillHandler (From LOSES there — retire is self-inflicted).
// Intentional — do NOT "consistency-fix" the two handlers to match; a swap here
// silently reverses every lethal-turn outcome.
return new[] return new[]
{ {
new DispatchRoute(ctx.Other, ctx.Env, false), new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal),
new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true), new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), Stock.Bypass),
new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true), new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), Stock.Bypass),
}; };
} }

View File

@@ -8,20 +8,20 @@ internal sealed class TurnEndHandler : IFrameHandler
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
{ {
// case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI). // case 4: Bot — Judge to sender only (no real opponent; client flips back to its local AI).
if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady) if (ctx.OpponentIsAckOnly && ctx.SenderIsAfterReady)
return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), false) }; return new[] { new DispatchRoute(ctx.From, BattleFrames.BuildJudgeBroadcast(), Stock.Normal) };
// case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent // case 8: general AfterReady arm — PvP forwards a {turnState} TurnEnd to the opponent
// (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame. // (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame.
if (ctx.SenderPhase == BattleSessionPhase.AfterReady) if (ctx.SenderIsAfterReady)
{ {
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) if (ctx.BothSidesAfterReady())
{ {
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate): // Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
// the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects // the opponent (the turn taker-over) then sends a Judge, which JudgeHandler reflects
// back to it to start its turn. battleCode/actionSeq/cemetery are dropped. // back to it to start its turn. battleCode/actionSeq/cemetery are dropped.
var te = ctx.Env with { Body = new TurnEndBody(TurnState: 0) }; var te = ctx.Env with { Body = new TurnEndBody(TurnState: TurnState.First) };
return new[] { new DispatchRoute(ctx.Other, te, false) }; return new[] { new DispatchRoute(ctx.Other, te, Stock.Normal) };
} }
return Array.Empty<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above) return Array.Empty<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above)
} }

View File

@@ -1,3 +1,4 @@
using SVSim.BattleNode.Lifecycle;
using SVSim.BattleNode.Protocol; using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Protocol.Bodies;
@@ -9,10 +10,10 @@ internal sealed class TurnStartHandler : IFrameHandler
{ {
// PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin} // PvP: the active player's TurnStart{orderList} is dropped; the opponent receives {spin}
// (spin=0 for the deterministic-turn slice) and self-generates its turn-open. // (spin=0 for the deterministic-turn slice) and self-generates its turn-open.
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) if (ctx.BothSidesAfterReady())
{ {
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) }; var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: BattleFrameDefaults.DeterministicTurnSpin) };
return new[] { new DispatchRoute(ctx.Other, frame, false) }; return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
} }
return Array.Empty<DispatchRoute>(); return Array.Empty<DispatchRoute>();

View File

@@ -1,3 +1,4 @@
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.BattleNode.Sessions.Dispatch; namespace SVSim.BattleNode.Sessions.Dispatch;
@@ -5,7 +6,7 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
/// <summary>Pure transforms from the active player's RawBody sub-structures to the opponent-facing /// <summary>Pure transforms from the active player's RawBody sub-structures to the opponent-facing
/// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive /// shapes. No session state, no wire I/O — unit-testable in isolation. RawBody nested values arrive
/// as <c>Dictionary&lt;string,object?&gt;</c> / <c>List&lt;object?&gt;</c> with numeric leaves boxed /// as <c>Dictionary&lt;string,object?&gt;</c> / <c>List&lt;object?&gt;</c> with numeric leaves boxed
/// as long/int/double (see MsgEnvelope.FromJson).</summary> /// as long/int/double (see MsgEnvelope.FromJson). Inbound wire keys come from <see cref="WireKeys"/>.</summary>
internal static class KnownListBuilder internal static class KnownListBuilder
{ {
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized /// <summary>The played card's knownList entry, or null when its identity can't be synthesized
@@ -31,11 +32,11 @@ internal static class KnownListBuilder
foreach (var op in ops) foreach (var op in ops)
{ {
if (op is not IDictionary<string, object?> opDict) continue; if (op is not IDictionary<string, object?> opDict) continue;
if (!opDict.TryGetValue("move", out var moveRaw) || moveRaw is not IDictionary<string, object?> move) continue; if (!opDict.TryGetValue(WireKeys.Move, out var moveRaw) || moveRaw is not IDictionary<string, object?> move) continue;
if (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable<object?> idxList) if (move.TryGetValue(WireKeys.Idx, out var idxRaw) && idxRaw is IEnumerable<object?> idxList)
{ {
foreach (var i in idxList) foreach (var i in idxList)
if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw)) if (AsLong(i) == playIdx && move.TryGetValue(WireKeys.To, out var toRaw))
return (int)AsLong(toRaw); return (int)AsLong(toRaw);
} }
} }
@@ -54,24 +55,24 @@ internal static class KnownListBuilder
/// <c>idx</c>-is-list guards. This is the only place a freshly-generated card's identity exists on /// <c>idx</c>-is-list guards. This is the only place a freshly-generated card's identity exists on
/// the wire (bullet-3 audit F1; producing code <c>RegisterToken</c>/<c>RegisterActionBase</c>) — /// the wire (bullet-3 audit F1; producing code <c>RegisterToken</c>/<c>RegisterActionBase</c>) —
/// the played-card op itself never carries a <c>cardId</c>.</summary> /// the played-card op itself never carries a <c>cardId</c>.</summary>
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineAddOps(object? orderList) public static IEnumerable<MinedToken> MineAddOps(object? orderList)
{ {
if (orderList is not IEnumerable<object?> ops) yield break; if (orderList is not IEnumerable<object?> ops) yield break;
foreach (var op in ops) foreach (var op in ops)
{ {
if (op is not IDictionary<string, object?> opDict) continue; if (op is not IDictionary<string, object?> opDict) continue;
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue; if (!opDict.TryGetValue(WireKeys.Add, out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
add.TryGetValue("isSelf", out var isSelfRaw); add.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
var isSelf = (int)AsLong(isSelfRaw); var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue; if (!add.TryGetValue(WireKeys.Card, out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
if (!card.TryGetValue("cardId", out var cardIdRaw)) continue; // candidates/isChoice → no identity yet if (!card.TryGetValue(WireKeys.CardId, out var cardIdRaw)) continue; // candidates/isChoice → no identity yet
var cardId = AsLong(cardIdRaw); var cardId = AsLong(cardIdRaw);
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue; if (!add.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
foreach (var i in idxList) foreach (var i in idxList)
yield return ((int)AsLong(i), cardId, isSelf); yield return new MinedToken((int)AsLong(i), cardId, isSelf);
} }
} }
@@ -86,7 +87,7 @@ internal static class KnownListBuilder
/// only gates the strip (<see cref="StripKeyActionForOpponent"/>), not the recording. An add whose /// only gates the strip (<see cref="StripKeyActionForOpponent"/>), not the recording. An add whose
/// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no /// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no
/// keyAction) yields nothing, leaving it mining-only via <see cref="MineAddOps"/>.</summary> /// keyAction) yields nothing, leaving it mining-only via <see cref="MineAddOps"/>.</summary>
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineChoicePicks(object? orderList, object? keyAction) public static IEnumerable<MinedToken> MineChoicePicks(object? orderList, object? keyAction)
{ {
if (orderList is not IEnumerable<object?> ops) yield break; if (orderList is not IEnumerable<object?> ops) yield break;
@@ -97,8 +98,8 @@ internal static class KnownListBuilder
foreach (var ka in kaEntries) foreach (var ka in kaEntries)
{ {
if (ka is not IDictionary<string, object?> kaDict) continue; if (ka is not IDictionary<string, object?> kaDict) continue;
if (!kaDict.TryGetValue("selectCard", out var scRaw) || scRaw is not IDictionary<string, object?> sc) continue; if (!kaDict.TryGetValue(WireKeys.SelectCard, out var scRaw) || scRaw is not IDictionary<string, object?> sc) continue;
if (!sc.TryGetValue("cardId", out var idsRaw) || idsRaw is not IEnumerable<object?> ids) continue; if (!sc.TryGetValue(WireKeys.CardId, out var idsRaw) || idsRaw is not IEnumerable<object?> ids) continue;
foreach (var id in ids) picks.Add(AsLong(id)); foreach (var id in ids) picks.Add(AsLong(id));
} }
} }
@@ -107,10 +108,10 @@ internal static class KnownListBuilder
foreach (var op in ops) foreach (var op in ops)
{ {
if (op is not IDictionary<string, object?> opDict) continue; if (op is not IDictionary<string, object?> opDict) continue;
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue; if (!opDict.TryGetValue(WireKeys.Add, out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
if (!add.ContainsKey("isChoice")) continue; if (!add.ContainsKey(WireKeys.IsChoice)) continue;
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue; if (!add.TryGetValue(WireKeys.Card, out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
if (!card.TryGetValue("candidates", out var candRaw) || candRaw is not IEnumerable<object?> candidates) continue; if (!card.TryGetValue(WireKeys.Candidates, out var candRaw) || candRaw is not IEnumerable<object?> candidates) continue;
// The chosen cardId is the candidate that the active player picked (∈ picks). One per op. // The chosen cardId is the candidate that the active player picked (∈ picks). One per op.
long? chosen = null; long? chosen = null;
@@ -121,12 +122,12 @@ internal static class KnownListBuilder
} }
if (chosen is null) continue; // no pick in this op's pool — skip (no desync, just no record) if (chosen is null) continue; // no pick in this op's pool — skip (no desync, just no record)
add.TryGetValue("isSelf", out var isSelfRaw); add.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
var isSelf = (int)AsLong(isSelfRaw); var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue; if (!add.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
foreach (var i in idxList) foreach (var i in idxList)
yield return ((int)AsLong(i), chosen.Value, isSelf); yield return new MinedToken((int)AsLong(i), chosen.Value, isSelf);
} }
} }
@@ -143,7 +144,7 @@ internal static class KnownListBuilder
/// <c>candidates</c> (→ MineChoicePicks), a <c>string</c> <c>baseIdx</c> (private-group copy, /// <c>candidates</c> (→ MineChoicePicks), a <c>string</c> <c>baseIdx</c> (private-group copy,
/// <c>RegisterCopyToken.cs:19-22</c>), and a <c>baseIdx</c> absent from the chosen map (unknown source /// <c>RegisterCopyToken.cs:19-22</c>), and a <c>baseIdx</c> absent from the chosen map (unknown source
/// → degrade, no desync). <c>isPremium</c> (IsFoil) is cosmetic and ignored.</summary> /// → degrade, no desync). <c>isPremium</c> (IsFoil) is cosmetic and ignored.</summary>
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineCopyTokens( public static IEnumerable<MinedToken> MineCopyTokens(
object? orderList, object? orderList,
IReadOnlyDictionary<int, long> selfMap, IReadOnlyDictionary<int, long> selfMap,
IReadOnlyDictionary<int, long> otherMap) IReadOnlyDictionary<int, long> otherMap)
@@ -152,22 +153,22 @@ internal static class KnownListBuilder
foreach (var op in ops) foreach (var op in ops)
{ {
if (op is not IDictionary<string, object?> opDict) continue; if (op is not IDictionary<string, object?> opDict) continue;
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue; if (!opDict.TryGetValue(WireKeys.Add, out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue; if (!add.TryGetValue(WireKeys.Card, out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
if (card.ContainsKey("cardId")) continue; // concrete token → MineAddOps if (card.ContainsKey(WireKeys.CardId)) continue; // concrete token → MineAddOps
if (!card.TryGetValue("baseIdx", out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks) if (!card.TryGetValue(WireKeys.BaseIdx, out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks)
if (baseRaw is string) continue; // private-group copy → string baseIdx, skip if (baseRaw is string) continue; // private-group copy → string baseIdx, skip
var baseIdx = (int)AsLong(baseRaw); var baseIdx = (int)AsLong(baseRaw);
add.TryGetValue("isSelf", out var isSelfRaw); add.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
var isSelf = (int)AsLong(isSelfRaw); var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
var map = isSelf == 1 ? selfMap : otherMap; var map = isSelf == CardOwner.Self ? selfMap : otherMap;
if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue; if (!add.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
foreach (var i in idxList) foreach (var i in idxList)
yield return ((int)AsLong(i), cardId, isSelf); yield return new MinedToken((int)AsLong(i), cardId, isSelf);
} }
} }
@@ -184,19 +185,19 @@ internal static class KnownListBuilder
foreach (var e in entries) foreach (var e in entries)
{ {
if (e is not IDictionary<string, object?> d) continue; if (e is not IDictionary<string, object?> d) continue;
d.TryGetValue("type", out var typeRaw); d.TryGetValue(WireKeys.Type, out var typeRaw);
var type = (int)AsLong(typeRaw); var type = (KeyActionType)(int)AsLong(typeRaw);
if (type is not (1 or 5)) continue; // only Choice / HaveBeforeSkillChoice handled if (type is not (KeyActionType.Choice or KeyActionType.HaveBeforeSkillChoice)) continue;
d.TryGetValue("cardId", out var cardIdRaw); d.TryGetValue(WireKeys.CardId, out var cardIdRaw);
var cardId = AsLong(cardIdRaw); var cardId = AsLong(cardIdRaw);
SelectCardEntry? selectCard = null; SelectCardEntry? selectCard = null;
if (d.TryGetValue("selectCard", out var scRaw) && scRaw is IDictionary<string, object?> sc) if (d.TryGetValue(WireKeys.SelectCard, out var scRaw) && scRaw is IDictionary<string, object?> sc)
{ {
sc.TryGetValue("open", out var openRaw); sc.TryGetValue(WireKeys.Open, out var openRaw);
var open = (int)AsLong(openRaw); var open = (ChoiceVisibility)(int)AsLong(openRaw);
if (open != 0 && sc.TryGetValue("cardId", out var idsRaw) && idsRaw is IEnumerable<object?> ids) if (open != ChoiceVisibility.Hidden && sc.TryGetValue(WireKeys.CardId, out var idsRaw) && idsRaw is IEnumerable<object?> ids)
selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open); selectCard = new SelectCardEntry(ids.Select(AsLong).ToList(), open);
} }
result.Add(new KeyActionEntry(type, cardId, selectCard)); result.Add(new KeyActionEntry(type, cardId, selectCard));
@@ -213,11 +214,11 @@ internal static class KnownListBuilder
foreach (var e in entries) foreach (var e in entries)
{ {
if (e is not IDictionary<string, object?> d) continue; if (e is not IDictionary<string, object?> d) continue;
d.TryGetValue("targetIdx", out var targetIdxRaw); d.TryGetValue(WireKeys.TargetIdx, out var targetIdxRaw);
d.TryGetValue("isSelf", out var isSelfRaw); d.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
result.Add(new OppoTargetEntry( result.Add(new OppoTargetEntry(
TargetIdx: (int)AsLong(targetIdxRaw), TargetIdx: (int)AsLong(targetIdxRaw),
IsSelf: (int)AsLong(isSelfRaw))); IsSelf: (CardOwner)(int)AsLong(isSelfRaw)));
} }
return result.Count == 0 ? null : result; return result.Count == 0 ? null : result;
} }
@@ -237,25 +238,25 @@ internal static class KnownListBuilder
{ {
if (e is not IDictionary<string, object?> d) continue; if (e is not IDictionary<string, object?> d) continue;
d.TryGetValue("idxList", out var idxRaw); d.TryGetValue(WireKeys.IdxList, out var idxRaw);
d.TryGetValue("from", out var fromRaw); d.TryGetValue(WireKeys.From, out var fromRaw);
d.TryGetValue("to", out var toRaw); d.TryGetValue(WireKeys.To, out var toRaw);
d.TryGetValue("isSelf", out var isSelfRaw); d.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
d.TryGetValue("skill", out var skillRaw); d.TryGetValue(WireKeys.Skill, out var skillRaw);
result.Add(new UnapprovedCardEntry( result.Add(new UnapprovedCardEntry(
IdxList: AsIntList(idxRaw) ?? new List<int>(), IdxList: AsIntList(idxRaw) ?? new List<int>(),
From: (int)AsLong(fromRaw), From: (int)AsLong(fromRaw),
To: (int)AsLong(toRaw), To: (int)AsLong(toRaw),
IsSelf: (int)AsLong(isSelfRaw), IsSelf: (CardOwner)(int)AsLong(isSelfRaw),
Skill: skillRaw as string ?? "", Skill: skillRaw as string ?? "",
CardId: d.TryGetValue("cardId", out var c) ? AsLong(c) : null, CardId: d.TryGetValue(WireKeys.CardId, out var c) ? AsLong(c) : null,
Clan: d.TryGetValue("clan", out var cl) ? (int)AsLong(cl) : null, Clan: d.TryGetValue(WireKeys.Clan, out var cl) ? (int)AsLong(cl) : null,
Cost: d.TryGetValue("cost", out var co) ? (int)AsLong(co) : null, Cost: d.TryGetValue(WireKeys.Cost, out var co) ? (int)AsLong(co) : null,
SkillKeyCardIdx: AsIntList(d.TryGetValue("skillKeyCardIdx", out var sk) ? sk : null), SkillKeyCardIdx: AsIntList(d.TryGetValue(WireKeys.SkillKeyCardIdx, out var sk) ? sk : null),
RandomTargetIdx: AsIntList(d.TryGetValue("randomTargetIdx", out var rt) ? rt : null), RandomTargetIdx: AsIntList(d.TryGetValue(WireKeys.RandomTargetIdx, out var rt) ? rt : null),
IsInvoke: d.TryGetValue("isInvoke", out var iv) ? (int)AsLong(iv) : null, IsInvoke: d.TryGetValue(WireKeys.IsInvoke, out var iv) ? AsLong(iv) != 0 : null,
AttachTarget: d.TryGetValue("attachTarget", out var at) ? at as string : null)); AttachTarget: d.TryGetValue(WireKeys.AttachTarget, out var at) ? at as string : null));
} }
return result.Count == 0 ? null : result; return result.Count == 0 ? null : result;
} }

View File

@@ -0,0 +1,15 @@
using SVSim.BattleNode.Protocol;
namespace SVSim.BattleNode.Sessions.Dispatch;
/// <summary>One generated-token identity mined from a sender's <c>orderList</c> <c>add</c> op:
/// the token's <paramref name="Idx"/> in a side's index space, its resolved
/// <paramref name="CardId"/>, and <paramref name="IsSelf"/> — whose map it belongs to (the
/// sender's own token vs a cross-side gift living in the opponent's index space; routed by
/// <see cref="BattleSessionState.RecordTokensFrom"/>). Replaces the transpose-prone
/// <c>(int Idx, long CardId, CardOwner IsSelf)</c> tuple the <c>Mine*</c> methods returned:
/// <c>Idx</c> and <c>CardId</c> are both numeric, so <c>(cardId, idx, …)</c> silently compiled
/// and corrupted the reveal map. As a positional record struct it keeps the named members and
/// positional deconstruct (call sites stay <c>foreach (var (idx, cardId, isSelf) in …)</c>)
/// while the compiler rejects a transposed construction.</summary>
internal readonly record struct MinedToken(int Idx, long CardId, CardOwner IsSelf);

View File

@@ -0,0 +1,50 @@
namespace SVSim.BattleNode.Sessions.Dispatch;
/// <summary>
/// Single source of truth for the inbound-body (RawBody / orderList) wire-key strings the dispatch
/// path reads off the client's frames. These are the SENDER's JSON keys (mirroring the client's
/// <c>SendCardDataMaker</c> / <c>CardObj</c> serialization); a one-character typo at a read site
/// (<c>"isSelf"</c> vs <c>"IsSelf"</c>) silently degrades token resolution with no error, so every
/// read goes through a constant here instead of a repeated literal. Outbound keys stay on the
/// per-DTO <c>[JsonPropertyName]</c> attributes (already single-sourced there).
/// </summary>
internal static class WireKeys
{
// Top-level inbound body keys
public const string OrderList = "orderList";
public const string KeyAction = "keyAction";
public const string PlayIdx = "playIdx";
public const string Type = "type";
public const string TargetList = "targetList";
public const string UList = "uList";
// orderList op keys
public const string Move = "move";
public const string Add = "add";
public const string Idx = "idx";
public const string To = "to";
public const string IsSelf = "isSelf";
public const string Card = "card";
public const string CardId = "cardId";
public const string Candidates = "candidates";
public const string IsChoice = "isChoice";
public const string BaseIdx = "baseIdx";
// keyAction.selectCard keys
public const string SelectCard = "selectCard";
public const string Open = "open";
// targetList entry keys
public const string TargetIdx = "targetIdx";
// uList entry keys
public const string IdxList = "idxList";
public const string From = "from";
public const string Skill = "skill";
public const string Clan = "clan";
public const string Cost = "cost";
public const string SkillKeyCardIdx = "skillKeyCardIdx";
public const string RandomTargetIdx = "randomTargetIdx";
public const string IsInvoke = "isInvoke";
public const string AttachTarget = "attachTarget";
}

View File

@@ -0,0 +1,18 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Per-participant progression through the v1 server-authored setup handshake. Each side advances
/// InitNetwork → InitBattle → Loaded → Swap → AfterReady as the session acks its emits. Tracked
/// per participant via <see cref="Participants.IHasHandshakePhase"/>; the session reads the
/// SENDER's phase (<see cref="Dispatch.FrameDispatchContext.SenderPhase"/>) to gate which setup
/// frame to author next. Distinct from the session-global <see cref="SessionLifecycle"/> — this is
/// one axis per side, that is one axis per battle.
/// </summary>
public enum HandshakePhase
{
AwaitingInitNetwork,
AwaitingInitBattle,
AwaitingLoaded,
AwaitingSwap,
AfterReady,
}

View File

@@ -24,9 +24,9 @@ public interface IBattleParticipant : IAsyncDisposable
/// <summary>Session calls this to deliver a frame from the OTHER participant /// <summary>Session calls this to deliver a frame from the OTHER participant
/// (or a server-synthesized broadcast). Real impl: encode + WS-send. /// (or a server-synthesized broadcast). Real impl: encode + WS-send.
/// NoOp: swallow.</summary> /// NoOp: swallow.</summary>
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack); /// <param name="stock"><see cref="Stock.Bypass"/> for control frames (BattleFinish, JudgeResult,
/// bypasses playSeq assignment + archive.</param> /// ack) — bypasses playSeq assignment + archive; <see cref="Stock.Normal"/> for gameplay frames.</param>
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct); Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct);
/// <summary>Participant fires this when it has a frame to send TO the session /// <summary>Participant fires this when it has a frame to send TO the session
/// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.</summary> /// (its own gameplay action). Real impl: fires on WS recv. NoOp: never fires.</summary>

View File

@@ -2,8 +2,10 @@ namespace SVSim.BattleNode.Sessions;
public interface IBattleSessionStore public interface IBattleSessionStore
{ {
/// <summary>Register a battle minted by the matching bridge, awaiting a WS connect.</summary> /// <summary>Register a battle minted by the matching bridge, awaiting a WS connect.
void RegisterPending(PendingBattle battle); /// Returns false if a battle with the same id is already pending (caller should retry
/// with a fresh id).</summary>
bool TryRegisterPending(PendingBattle battle);
/// <summary>Look up the pending battle. Returns null if not present.</summary> /// <summary>Look up the pending battle. Returns null if not present.</summary>
PendingBattle? TryGetPending(string battleId); PendingBattle? TryGetPending(string battleId);

View File

@@ -6,8 +6,8 @@ public sealed class InMemoryBattleSessionStore : IBattleSessionStore
{ {
private readonly ConcurrentDictionary<string, PendingBattle> _pending = new(); private readonly ConcurrentDictionary<string, PendingBattle> _pending = new();
public void RegisterPending(PendingBattle battle) => public bool TryRegisterPending(PendingBattle battle) =>
_pending[battle.BattleId] = battle; _pending.TryAdd(battle.BattleId, battle);
public PendingBattle? TryGetPending(string battleId) => public PendingBattle? TryGetPending(string battleId) =>
_pending.TryGetValue(battleId, out var b) ? b : null; _pending.TryGetValue(battleId, out var b) ? b : null;

View File

@@ -14,22 +14,25 @@ namespace SVSim.BattleNode.Sessions.Participants;
/// </summary> /// </summary>
public sealed class NoOpBotParticipant : IBattleParticipant public sealed class NoOpBotParticipant : IBattleParticipant
{ {
/// <summary>Stub card-master id stamped on the bot's (never-read) MatchContext.</summary>
private const string BotCardMasterName = "card_master_node_10015";
public long ViewerId => ServerBattleFrames.FakeOpponentViewerId; public long ViewerId => ServerBattleFrames.FakeOpponentViewerId;
public MatchContext Context { get; } = new( public MatchContext Context { get; } = new(
SelfDeckCardIds: Array.Empty<long>(), SelfDeckCardIds: Array.Empty<long>(),
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", ClassId: CardClass.None, CharaId: "0", CardMasterName: BotCardMasterName,
CountryCode: "", UserName: "Bot", SleeveId: "0", CountryCode: "", UserName: "Bot", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
BattleType: 0); BattleModeId: 0);
// Required by IBattleParticipant, but a silent bot never raises it — suppress the
// "event is never used" warning rather than keeping a dead null-emitting method.
#pragma warning disable CS0067
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted; public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
#pragma warning restore CS0067
public Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) => Task.CompletedTask; public Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct) => Task.CompletedTask;
public Task RunAsync(CancellationToken ct) => Task.CompletedTask; public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
// Suppress unused-event warning — FrameEmitted is declared by the interface contract;
// intentionally never invoked.
private void Touch() => FrameEmitted?.Invoke(null!, default);
} }

View File

@@ -19,7 +19,7 @@ namespace SVSim.BattleNode.Sessions.Participants;
/// </summary> /// </summary>
internal interface IHasHandshakePhase internal interface IHasHandshakePhase
{ {
BattleSessionPhase Phase { get; set; } HandshakePhase Phase { get; set; }
} }
/// <summary> /// <summary>
@@ -31,6 +31,24 @@ internal interface IHasHandshakePhase
/// </summary> /// </summary>
public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{ {
/// <summary>WS read-loop receive buffer, in bytes. Messages larger than this are
/// reassembled across multiple ReceiveAsync calls (see <see cref="ReadCompleteMessageAsync"/>).</summary>
private const int ReceiveBufferBytes = 8192;
/// <summary>Engine.IO heartbeat parameters advertised in the open handshake — the
/// pingInterval/pingTimeout (ms) the BestHTTP client honors. Not related to
/// <see cref="Bridge.BattleNodeOptions.WaitingRoomTimeout"/> despite the 60s coincidence.</summary>
private const int EngineIoPingIntervalMs = 25000;
private const int EngineIoPingTimeoutMs = 60000;
/// <summary>Length (hex chars) of the Engine.IO session id we mint in the open handshake.</summary>
private const int EngineIoSidLength = 16;
/// <summary>Exclusive upper bound for one random hex nibble (0x0..0xF) fed to
/// <see cref="NodeCrypto.GenerateKey"/>. Distinct concept from <see cref="EngineIoSidLength"/>
/// despite the shared value 16.</summary>
private const int KeyHexDigitExclusiveMax = 16;
private readonly WebSocket _ws; private readonly WebSocket _ws;
private readonly ILogger<RealParticipant> _log; private readonly ILogger<RealParticipant> _log;
private readonly bool _diagnosticLogging; private readonly bool _diagnosticLogging;
@@ -48,9 +66,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
/// because they never send the gating URIs. Also satisfies /// because they never send the gating URIs. Also satisfies
/// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate /// <see cref="IHasHandshakePhase"/> (the interface BattleSession uses to gate
/// handshake dispatch without depending on the concrete RealParticipant type).</summary> /// handshake dispatch without depending on the concrete RealParticipant type).</summary>
internal BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; internal HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
BattleSessionPhase IHasHandshakePhase.Phase HandshakePhase IHasHandshakePhase.Phase
{ {
get => Phase; get => Phase;
set => Phase = value; set => Phase = value;
@@ -100,7 +118,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
_sessionCt = cancellation; _sessionCt = cancellation;
await SendEioOpenAsync(cancellation); await SendEioOpenAsync(cancellation);
var buffer = new byte[8192]; var buffer = new byte[ReceiveBufferBytes];
var pendingAttachments = new List<byte[]>(); var pendingAttachments = new List<byte[]>();
SocketIoFrame? pendingFrame = null; SocketIoFrame? pendingFrame = null;
string exitReason = "loop-condition-false"; string exitReason = "loop-condition-false";
@@ -116,7 +134,15 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
{ {
var text = Encoding.UTF8.GetString(msg.Value.Bytes); var text = Encoding.UTF8.GetString(msg.Value.Bytes);
if (text.Length == 0) continue; if (text.Length == 0) continue;
var eio = EngineIoFrame.Parse(text);
EngineIoFrame eio;
try { eio = EngineIoFrame.Parse(text); }
catch (ArgumentException ex)
{
_log.LogWarning(ex, "Dropping unparseable EIO frame from viewer {Vid}", ViewerId);
continue;
}
if (_diagnosticLogging) if (_diagnosticLogging)
{ {
_log.LogInformation( _log.LogInformation(
@@ -126,12 +152,18 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
} }
if (eio.Type == EngineIoPacketType.Ping) if (eio.Type == EngineIoPacketType.Ping)
{ {
await SendTextAsync("3", cancellation); await SendTextAsync(((int)EngineIoPacketType.Pong).ToString(), cancellation);
continue; continue;
} }
if (eio.Type != EngineIoPacketType.Message) continue; if (eio.Type != EngineIoPacketType.Message) continue;
var sio = SocketIoFrame.Parse(eio.Payload); SocketIoFrame sio;
try { sio = SocketIoFrame.Parse(eio.Payload); }
catch (ArgumentException ex)
{
_log.LogWarning(ex, "Dropping unparseable SIO frame from viewer {Vid}", ViewerId);
continue;
}
if (sio.AttachmentCount > 0) if (sio.AttachmentCount > 0)
{ {
pendingFrame = sio; pendingFrame = sio;
@@ -179,14 +211,14 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
} }
} }
public async Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) public async Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct)
{ {
var stamped = noStock ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope); var stamped = stock == Stock.Bypass ? Outbound.WrapNoStock(envelope) : Outbound.AssignAndArchive(envelope);
if (_diagnosticLogging) if (_diagnosticLogging)
{ {
_log.LogInformation( _log.LogInformation(
"[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}", "[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} stock={Stock}",
ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock); ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, stock);
} }
await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct); await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct);
} }
@@ -372,7 +404,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
ViewerId: SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId, ViewerId: SVSim.BattleNode.Lifecycle.ServerBattleFrames.FakeOpponentViewerId,
Uuid: WireConstants.ServerUuid, Uuid: WireConstants.ServerUuid,
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.General, Cat: EmitCategory.General,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
@@ -388,7 +420,7 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName, CancellationToken ct) private async Task EncodeAndSendAsync(MsgEnvelope env, string eventName, CancellationToken ct)
{ {
var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, 16)); var key = NodeCrypto.GenerateKey(() => RandomNumberGenerator.GetInt32(0, KeyHexDigitExclusiveMax));
var bytes = MsgPayloadCodec.Encode(env, key); var bytes = MsgPayloadCodec.Encode(env, key);
var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes }); var sio = SocketIoFrame.BinaryEventWithAttachments(eventName, new[] { bytes });
var (text, bins) = sio.Encode(); var (text, bins) = sio.Encode();
@@ -428,8 +460,9 @@ public sealed class RealParticipant : IBattleParticipant, IHasHandshakePhase
private async Task SendEioOpenAsync(CancellationToken ct) private async Task SendEioOpenAsync(CancellationToken ct)
{ {
var sid = Guid.NewGuid().ToString("N").Substring(0, 16); var sid = Guid.NewGuid().ToString("N").Substring(0, EngineIoSidLength);
var handshake = new EngineIoHandshake(sid, Array.Empty<string>(), 25000, 60000).ToJson(); var handshake = new EngineIoHandshake(
sid, Array.Empty<string>(), EngineIoPingIntervalMs, EngineIoPingTimeoutMs).ToJson();
await SendTextAsync($"0{handshake}", ct); await SendTextAsync($"0{handshake}", ct);
} }

View File

@@ -0,0 +1,15 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// Session-global lifecycle. A battle stays <see cref="Active"/> until a terminal event — a lethal
/// TurnEndFinal, a Retire/Kill, or the disconnect drop cascade — flips it to <see cref="Terminal"/>,
/// after which the drop cascade will not synthesize another BattleFinish. Distinct from the
/// per-participant <see cref="HandshakePhase"/> (which side reached which setup step); this is one
/// axis per battle. Only these two states are load-bearing — the handshake progression lives on the
/// other enum.
/// </summary>
public enum SessionLifecycle
{
Active,
Terminal,
}

View File

@@ -0,0 +1,17 @@
namespace SVSim.BattleNode.Sessions;
/// <summary>
/// How a pushed frame interacts with the per-participant <c>OutboundSequencer</c>: whether it
/// gets a <c>playSeq</c> and is archived for ordered replay, or bypasses both. Replaces a bare
/// (and negatively-named) <c>bool noStock</c> threaded through <see cref="IBattleParticipant.PushAsync"/>
/// and <see cref="Dispatch.DispatchRoute"/> — the literal <c>true</c>/<c>false</c> at call sites gave
/// no hint which sense was which, and was trivial to invert.
/// </summary>
public enum Stock
{
/// <summary>Gameplay frame: assign the next <c>playSeq</c> and archive it for ordered replay.</summary>
Normal = 0,
/// <summary>Control frame (BattleFinish, JudgeResult, ack): bypass <c>playSeq</c> assignment + archive.</summary>
Bypass = 1,
}

View File

@@ -12,11 +12,5 @@ public sealed record EngineIoHandshake(
[property: JsonPropertyName("pingInterval")] int PingInterval, [property: JsonPropertyName("pingInterval")] int PingInterval,
[property: JsonPropertyName("pingTimeout")] int PingTimeout) [property: JsonPropertyName("pingTimeout")] int PingTimeout)
{ {
// Wire-key casing here is bare camelCase — NOT EmulatedEntrypoint's snake_case policy. public string ToJson() => JsonSerializer.Serialize(this, WireJsonOptions.CamelCase);
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public string ToJson() => JsonSerializer.Serialize(this, Options);
} }

View File

@@ -10,6 +10,12 @@ namespace SVSim.BattleNode.Wire;
/// </summary> /// </summary>
public static class NodeCrypto public static class NodeCrypto
{ {
/// <summary>Length of the ASCII key, in chars (AES-256 = 32 bytes = 32 ASCII chars).</summary>
private const int KeyLength = 32;
/// <summary>IV length, in chars. The node derives the IV from the first half of the key.</summary>
private const int IvLength = KeyLength / 2;
/// <summary> /// <summary>
/// Generate a fresh 32-char key for server-initiated encryption. /// Generate a fresh 32-char key for server-initiated encryption.
/// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with /// Calls <paramref name="randHexDigit"/> 32 times; the result is masked with
@@ -27,20 +33,20 @@ public static class NodeCrypto
/// </remarks> /// </remarks>
public static string GenerateKey(Func<int> randHexDigit) public static string GenerateKey(Func<int> randHexDigit)
{ {
var sb = new StringBuilder(32); var sb = new StringBuilder(KeyLength);
for (var i = 0; i < 32; i++) for (var i = 0; i < KeyLength; i++)
{ {
sb.Append((randHexDigit() & 0xF).ToString("x")); sb.Append((randHexDigit() & 0xF).ToString("x"));
} }
var ascii = Encoding.ASCII.GetBytes(sb.ToString()); var ascii = Encoding.ASCII.GetBytes(sb.ToString());
return Convert.ToBase64String(ascii).Substring(0, 32); return Convert.ToBase64String(ascii).Substring(0, KeyLength);
} }
/// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary> /// <summary>Encrypt: returns key + base64(AES-256-CBC(plain)).</summary>
public static string EncryptForNode(string plaintext, string key) public static string EncryptForNode(string plaintext, string key)
{ {
if (key.Length != 32) if (key.Length != KeyLength)
throw new ArgumentException($"Key must be exactly 32 chars, got {key.Length}", nameof(key)); throw new ArgumentException($"Key must be exactly {KeyLength} chars, got {key.Length}", nameof(key));
using var aes = BuildAes(key); using var aes = BuildAes(key);
using var encryptor = aes.CreateEncryptor(); using var encryptor = aes.CreateEncryptor();
var plainBytes = Encoding.UTF8.GetBytes(plaintext); var plainBytes = Encoding.UTF8.GetBytes(plaintext);
@@ -51,10 +57,10 @@ public static class NodeCrypto
/// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary> /// <summary>Decrypt: input[0..32] is key, input[32..] is base64(ciphertext).</summary>
public static string DecryptForNode(string encrypted) public static string DecryptForNode(string encrypted)
{ {
if (encrypted.Length < 32) if (encrypted.Length < KeyLength)
throw new ArgumentException("Encrypted blob is shorter than the 32-char key prefix", nameof(encrypted)); throw new ArgumentException($"Encrypted blob is shorter than the {KeyLength}-char key prefix", nameof(encrypted));
var key = encrypted.Substring(0, 32); var key = encrypted.Substring(0, KeyLength);
var cipherBytes = Convert.FromBase64String(encrypted.Substring(32)); var cipherBytes = Convert.FromBase64String(encrypted.Substring(KeyLength));
using var aes = BuildAes(key); using var aes = BuildAes(key);
using var decryptor = aes.CreateDecryptor(); using var decryptor = aes.CreateDecryptor();
var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); var plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
@@ -62,10 +68,21 @@ public static class NodeCrypto
} }
/// <summary> /// <summary>
/// Configure an AES-256-CBC instance with the node's IV derivation (first 16 chars /// Configure an AES-256-CBC instance with the node's IV derivation (first
/// of the key, UTF-8). Callers own disposal. Assumes <paramref name="key"/> is the /// <see cref="IvLength"/> chars of the key, UTF-8). Callers own disposal. Assumes
/// 32-char ASCII key the encrypt / decrypt path has already validated. /// <paramref name="key"/> is the <see cref="KeyLength"/>-char ASCII key the encrypt /
/// decrypt path has already validated.
/// </summary> /// </summary>
/// <remarks>
/// SECURITY (latent — do NOT "tidy" this into a cached key): the IV is derived from the key, so a
/// fixed key reuses a fixed IV — the classic CBC IV-reuse weakness (equal plaintext prefixes →
/// equal ciphertext prefixes). It is masked ONLY because every server-initiated send mints a fresh
/// key via <see cref="GenerateKey"/>, so (key, IV) never repeats in practice. A future change that
/// CACHES the session key would silently reintroduce the leak — derive a per-message random IV
/// first. Related: <see cref="GenerateKey"/> base64-truncates a hex string, so the effective key
/// entropy is well below what "AES-256" implies. We mirror the client's scheme deliberately; both
/// are acceptable only because this is a localhost relay, not a hostile-network transport.
/// </remarks>
private static Aes BuildAes(string key) private static Aes BuildAes(string key)
{ {
var aes = Aes.Create(); var aes = Aes.Create();
@@ -73,7 +90,7 @@ public static class NodeCrypto
aes.Mode = CipherMode.CBC; aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7; aes.Padding = PaddingMode.PKCS7;
aes.Key = Encoding.UTF8.GetBytes(key); aes.Key = Encoding.UTF8.GetBytes(key);
aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, 16)); aes.IV = Encoding.UTF8.GetBytes(key.Substring(0, IvLength));
return aes; return aes;
} }
} }

View File

@@ -52,7 +52,10 @@ public sealed class SocketIoFrame
if (string.IsNullOrEmpty(raw)) if (string.IsNullOrEmpty(raw))
throw new ArgumentException("Empty SIO payload", nameof(raw)); throw new ArgumentException("Empty SIO payload", nameof(raw));
var type = (SocketIoPacketType)(raw[0] - '0'); var typeChar = raw[0];
if (typeChar < '0' || typeChar > '6')
throw new ArgumentException($"Invalid SIO type char '{typeChar}'", nameof(raw));
var type = (SocketIoPacketType)(typeChar - '0');
var cursor = 1; var cursor = 1;
var attachmentCount = 0; var attachmentCount = 0;
@@ -84,7 +87,9 @@ public sealed class SocketIoFrame
{ {
var start = cursor; var start = cursor;
while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++; while (cursor < raw.Length && char.IsDigit(raw[cursor])) cursor++;
ackId = int.Parse(raw.AsSpan(start, cursor - start)); if (!int.TryParse(raw.AsSpan(start, cursor - start), out var parsedAckId))
throw new ArgumentException("SIO ack-id overflows int32", nameof(raw));
ackId = parsedAckId;
} }
var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty; var argsJson = cursor < raw.Length ? raw.Substring(cursor) : string.Empty;
@@ -153,10 +158,11 @@ public sealed class SocketIoFrame
binaryAttachments: attachments); binaryAttachments: attachments);
} }
/// <summary>Build an ack response with a single int argument (the spec's pubSeq echo).</summary> /// <summary>Build an ack response whose single argument echoes the inbound frame's pubSeq
public static SocketIoFrame AckResponse(int ackId, int arg) /// (the client's ordered-delivery cursor — load-bearing, not a placeholder).</summary>
public static SocketIoFrame AckResponse(int ackId, int pubSeqEcho)
{ {
var args = new JsonArray { arg }; var args = new JsonArray { pubSeqEcho };
return new SocketIoFrame( return new SocketIoFrame(
SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>()); SocketIoPacketType.Ack, ackId, 0, null, NodesToElements(args), Array.Empty<byte[]>());
} }

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace SVSim.BattleNode.Wire;
/// <summary>Shared System.Text.Json options for the bare-camelCase Socket.IO / Engine.IO wire:
/// per-field <c>[JsonPropertyName]</c> casing (NOT EmulatedEntrypoint's snake_case policy), null
/// fields omitted, and unattributed enums written as their name. Single-sourced here because
/// <see cref="EngineIoHandshake"/> and <see cref="Protocol.MsgEnvelope"/> previously each built a
/// byte-identical block in their own namespace — a drift hazard.</summary>
internal static class WireJsonOptions
{
public static readonly JsonSerializerOptions CamelCase = Create();
private static JsonSerializerOptions Create()
{
var opt = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
opt.Converters.Add(new JsonStringEnumConverter());
return opt;
}
}

View File

@@ -162,7 +162,8 @@ public sealed class RankBattleController : ControllerBase
} }
var selfCtx = pending.P1.Context; var selfCtx = pending.P1.Context;
var bot = await _botRoster.PickAsync(selfCtx, ct); var bot = await _botRoster.PickAsync(selfCtx, pending.BattleId, ct);
var seed = Random.Shared.Next();
// Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first). // Per spec, ai-start.md TODO: turnState semantics unclear. Default 0 (player first).
return Ok(new AiBattleStartResponseDto return Ok(new AiBattleStartResponseDto
@@ -179,10 +180,10 @@ public sealed class RankBattleController : ControllerBase
FieldId = selfCtx.FieldId, FieldId = selfCtx.FieldId,
IsOfficial = selfCtx.IsOfficial, IsOfficial = selfCtx.IsOfficial,
OppoId = bot.AiId, OppoId = bot.AiId,
Seed = 0, Seed = seed,
Rank = 0, Rank = 0,
BattlePoint = 0, BattlePoint = 0,
ClassId = int.TryParse(selfCtx.ClassId, out var cId) ? cId : -1, ClassId = (int)selfCtx.ClassId,
CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1, CharaId = int.TryParse(selfCtx.CharaId, out var chId) ? chId : -1,
IsMasterRank = 0, IsMasterRank = 0,
MasterPoint = 0, MasterPoint = 0,
@@ -197,7 +198,7 @@ public sealed class RankBattleController : ControllerBase
FieldId = bot.FieldId, FieldId = bot.FieldId,
IsOfficial = bot.IsOfficial, IsOfficial = bot.IsOfficial,
OppoId = (int)vid, OppoId = (int)vid,
Seed = 0, Seed = seed,
Rank = bot.Rank, Rank = bot.Rank,
BattlePoint = bot.BattlePoint, BattlePoint = bot.BattlePoint,
ClassId = bot.ClassId, ClassId = bot.ClassId,

View File

@@ -18,7 +18,7 @@ public sealed class BotRoster : IBotRoster
_globals = globals; _globals = globals;
} }
public async Task<AIBotProfile> PickAsync(MatchContext selfCtx, CancellationToken ct = default) public async Task<AIBotProfile> PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default)
{ {
var roster = await _globals.GetBotRoster(); var roster = await _globals.GetBotRoster();
if (roster.Count == 0) if (roster.Count == 0)
@@ -27,11 +27,9 @@ public sealed class BotRoster : IBotRoster
"BotRoster is empty. Run SVSim.Bootstrap to import seeds/bot-roster.json."); "BotRoster is empty. Run SVSim.Bootstrap to import seeds/bot-roster.json.");
} }
// Deterministic: hash the ctx and pick from the roster. Same ctx → // Deterministic per battle ID: same pending battle → same bot on retry,
// same bot so a mid-flight retry of /ai_<fmt>/start returns the same // but different battles get different opponents.
// opponent (no fresh roster pick on each call). var hash = StringComparer.Ordinal.GetHashCode(battleId);
var hash = StringComparer.Ordinal.GetHashCode(selfCtx.UserName)
^ StringComparer.Ordinal.GetHashCode(selfCtx.ClassId);
var index = (int)((uint)hash % roster.Count); var index = (int)((uint)hash % roster.Count);
var row = roster[index]; var row = roster[index];
return ToProfile(row); return ToProfile(row);

View File

@@ -14,9 +14,9 @@ namespace SVSim.EmulatedEntrypoint.Matching;
public interface IBotRoster public interface IBotRoster
{ {
/// <summary> /// <summary>
/// Returns a bot profile for the calling viewer. Deterministic per /// Returns a bot profile. Deterministic per <paramref name="battleId"/> so a
/// <see cref="MatchContext"/> — the same context value returns the same bot, so a /// mid-flight retry of <c>/ai_&lt;fmt&gt;/start</c> picks the same opponent,
/// mid-flight retry of <c>/ai_&lt;fmt&gt;/start</c> picks the same opponent. /// but different battles get different bots.
/// </summary> /// </summary>
Task<AIBotProfile> PickAsync(MatchContext selfCtx, CancellationToken ct = default); Task<AIBotProfile> PickAsync(MatchContext selfCtx, string battleId, CancellationToken ct = default);
} }

View File

@@ -54,7 +54,7 @@ public class MatchContextBuilder : IMatchContextBuilder
return new MatchContext( return new MatchContext(
SelfDeckCardIds: deck, SelfDeckCardIds: deck,
ClassId: run.ClassId.ToString(), ClassId: (CardClass)run.ClassId,
CharaId: charaId, CharaId: charaId,
// Hardcoded v1; see spec §Deferred plumbing. // Hardcoded v1; see spec §Deferred plumbing.
CardMasterName: "card_master_node_10015", CardMasterName: "card_master_node_10015",
@@ -67,7 +67,7 @@ public class MatchContextBuilder : IMatchContextBuilder
// Hardcoded v1; needs equipped-MyPageBackground lookup (see spec §Deferred). // Hardcoded v1; needs equipped-MyPageBackground lookup (see spec §Deferred).
FieldId: 43, FieldId: 43,
IsOfficial: viewer.Info.IsOfficial ? 1 : 0, IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
} }
public async Task<MatchContext> BuildForRankBattleAsync(long viewerId, Format format, int deckNo) public async Task<MatchContext> BuildForRankBattleAsync(long viewerId, Format format, int deckNo)
@@ -95,11 +95,16 @@ public class MatchContextBuilder : IMatchContextBuilder
var sleeveId = deck.Sleeve.Id != 0 var sleeveId = deck.Sleeve.Id != 0
? deck.Sleeve.Id.ToString() ? deck.Sleeve.Id.ToString()
: defaults.SleeveId.ToString(); : defaults.SleeveId.ToString();
var deckCardIds = deck.Cards.Select(c => c.Card.Id).ToList(); // DeckCard is count-based (one row per unique card + a Count). The node's deck
// is one entry PER PHYSICAL CARD (idx 1..N), so expand each row by its Count —
// otherwise a 3-copy card ships as a single in-battle card.
var deckCardIds = deck.Cards
.SelectMany(c => Enumerable.Repeat(c.Card.Id, c.Count))
.ToList();
return new MatchContext( return new MatchContext(
SelfDeckCardIds: deckCardIds, SelfDeckCardIds: deckCardIds,
ClassId: deck.Class.Id.ToString(), ClassId: (CardClass)deck.Class.Id,
CharaId: charaId, CharaId: charaId,
CardMasterName: "card_master_node_10015", CardMasterName: "card_master_node_10015",
CountryCode: viewer.Info.CountryCode ?? string.Empty, CountryCode: viewer.Info.CountryCode ?? string.Empty,
@@ -109,6 +114,6 @@ public class MatchContextBuilder : IMatchContextBuilder
DegreeId: degreeId, DegreeId: degreeId,
FieldId: 43, FieldId: 43,
IsOfficial: viewer.Info.IsOfficial ? 1 : 0, IsOfficial: viewer.Info.IsOfficial ? 1 : 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
} }
} }

View File

@@ -76,10 +76,25 @@ public class MatchingBridgeTests
new BattlePlayer(1, FixtureCtx()), new BattlePlayer(2, FixtureCtx()), BattleType.Bot)); new BattlePlayer(1, FixtureCtx()), new BattlePlayer(2, FixtureCtx()), BattleType.Bot));
} }
[Test]
public void RegisterBattle_evicts_stale_pending_for_same_viewer()
{
var store = new InMemoryBattleSessionStore();
var bridge = new MatchingBridge(store, new BattleNodeOptions());
var p1 = new BattlePlayer(42, FixtureCtx());
var first = bridge.RegisterBattle(p1, p2: null, BattleType.Bot);
Assert.That(store.TryGetPending(first.BattleId), Is.Not.Null);
var second = bridge.RegisterBattle(p1, p2: null, BattleType.Bot);
Assert.That(store.TryGetPending(first.BattleId), Is.Null, "stale entry must be evicted");
Assert.That(store.TryGetPending(second.BattleId), Is.Not.Null);
}
private static MatchContext FixtureCtx() => new( private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
} }

View File

@@ -69,10 +69,10 @@ public class WaitingRoomTests
var ws = new TestWebSocket(); var ws = new TestWebSocket();
var ctx = new MatchContext( var ctx = new MatchContext(
SelfDeckCardIds: Array.Empty<long>(), SelfDeckCardIds: Array.Empty<long>(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "0", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
return new RealParticipant(ws, viewerId, ctx, NullLogger<RealParticipant>.Instance); return new RealParticipant(ws, viewerId, ctx, NullLogger<RealParticipant>.Instance);
} }
} }

View File

@@ -23,10 +23,10 @@ public class BattleNodeFlowTests
internal static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new( internal static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
/// <summary> /// <summary>
/// End-to-end: a viewer with a real TK2 run sees their drafted card-ids in the Matched /// End-to-end: a viewer with a real TK2 run sees their drafted card-ids in the Matched
@@ -98,19 +98,28 @@ public class BattleNodeFlowTests
var body = ((RawBody)matched.Body).Entries; var body = ((RawBody)matched.Body).Entries;
var selfDeck = (List<object?>)body["selfDeck"]!; var selfDeck = (List<object?>)body["selfDeck"]!;
Assert.That(selfDeck.Count, Is.EqualTo(30)); Assert.That(selfDeck.Count, Is.EqualTo(30));
for (int i = 0; i < 30; i++)
// The node shuffles each deck per-battle from the master seed (see BattleSeeds /
// BattleSessionState.GetShuffledDeck), so cardIds are no longer in drafted order. What must
// hold: idxs are the contiguous 1..30 positions, and the set of cardIds is exactly the
// drafted deck (a permutation — same multiset, reordered).
var idxs = new List<long>(30);
var cardIds = new List<long>(30);
foreach (var e in selfDeck)
{ {
var entry = (Dictionary<string, object?>)selfDeck[i]!; var entry = (Dictionary<string, object?>)e!;
Assert.That((long)entry["idx"]!, Is.EqualTo(i + 1L), idxs.Add((long)entry["idx"]!);
$"slot {i}: idx should be 1-based position"); cardIds.Add((long)entry["cardId"]!);
Assert.That((long)entry["cardId"]!, Is.EqualTo(draftedDeck[i]),
$"slot {i}: cardId should match the drafted card");
} }
Assert.That(idxs, Is.EqualTo(Enumerable.Range(1, 30).Select(i => (long)i)),
"idxs are the contiguous 1-based positions 1..30");
Assert.That(cardIds, Is.EquivalentTo(draftedDeck),
"selfDeck is a permutation of the drafted deck (shuffled, same multiset)");
} }
private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq, private static MsgEnvelope MakeEnvelopeWith(long vid, NetworkBattleUri uri, long pubSeq,
Dictionary<string, object?>? body = null) => Dictionary<string, object?>? body = null) =>
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0, new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, RetryAttempt: 0,
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
: EmitCategory.Battle, : EmitCategory.Battle,

View File

@@ -250,7 +250,7 @@ public class CaptureConformanceTests
private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq, private static MsgEnvelope MakeEnvelope(long vid, NetworkBattleUri uri, long pubSeq,
Dictionary<string, object?>? body = null) => Dictionary<string, object?>? body = null) =>
new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, Try: 0, new(uri, ViewerId: vid, Uuid: "udid-test", Bid: null, RetryAttempt: 0,
Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General Cat: uri == NetworkBattleUri.InitNetwork ? EmitCategory.General
: uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching : uri == NetworkBattleUri.InitBattle ? EmitCategory.Matching
: EmitCategory.Battle, : EmitCategory.Battle,
@@ -287,7 +287,7 @@ public class CaptureConformanceTests
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null); PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
@@ -335,7 +335,7 @@ public class CaptureConformanceTests
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
PlayIdx: 38, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null); PlayIdx: 38, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
@@ -376,7 +376,7 @@ public class CaptureConformanceTests
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
PlayIdx: 37, Type: 30, KnownList: null, OppoTargetList: null, UList: relayed); PlayIdx: 37, Type: 30, KnownList: null, OppoTargetList: null, UList: relayed);
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
@@ -414,7 +414,7 @@ public class CaptureConformanceTests
.MineCopyTokens(orderList, new Dictionary<int, long>(), otherMap) .MineCopyTokens(orderList, new Dictionary<int, long>(), otherMap)
.ToList(); .ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (49, 123_456_789L, 0) })); Assert.That(mined, Is.EquivalentTo(new[] { new SVSim.BattleNode.Sessions.Dispatch.MinedToken(49, 123_456_789L, CardOwner.Opponent) }));
} }
[Test] [Test]
@@ -455,7 +455,7 @@ public class CaptureConformanceTests
var keyActionOut = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.StripKeyActionForOpponent(keyActionIn); var keyActionOut = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.StripKeyActionForOpponent(keyActionIn);
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
PlayIdx: 18, Type: 30, KnownList: new[] { played! }, OppoTargetList: null, KeyAction: keyActionOut); PlayIdx: 18, Type: 30, KnownList: new[] { played! }, OppoTargetList: null, KeyAction: keyActionOut);
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
@@ -520,7 +520,7 @@ public class CaptureConformanceTests
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody( var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
PlayIdx: 46, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null); PlayIdx: 46, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body); Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env)); using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));

View File

@@ -0,0 +1,46 @@
using NUnit.Framework;
using SVSim.BattleNode.Lifecycle;
namespace SVSim.UnitTests.BattleNode.Lifecycle;
[TestFixture]
public class BattleSeedsTests
{
// Golden values pin cross-run/cross-platform stability. They were computed from the exact
// splitmix64 mix specified in BattleSeeds. If these ever change, replay reproducibility broke —
// do NOT "update them to match"; find what changed the algorithm (e.g. someone slipped in
// GetHashCode, which is per-process randomized).
[Test]
public void Derive_golden_values_are_stable()
{
Assert.That(BattleSeeds.Stable(12345), Is.EqualTo(1577307848));
Assert.That(BattleSeeds.IdxChange(12345, 906243102), Is.EqualTo(1638231407));
Assert.That(BattleSeeds.DeckShuffle(12345, 906243102), Is.EqualTo(355953180));
Assert.That(BattleSeeds.IdxChange(12345, 847666884), Is.EqualTo(518125159));
Assert.That(BattleSeeds.Stable(99999), Is.EqualTo(323349150));
}
[Test]
public void Derive_is_deterministic_for_same_inputs()
{
Assert.That(BattleSeeds.Derive(7, "x", 42), Is.EqualTo(BattleSeeds.Derive(7, "x", 42)));
}
[Test]
public void Derive_differs_across_tag_master_and_discriminator()
{
var baseline = BattleSeeds.Derive(7, "x", 42);
Assert.That(BattleSeeds.Derive(8, "x", 42), Is.Not.EqualTo(baseline), "different master");
Assert.That(BattleSeeds.Derive(7, "y", 42), Is.Not.EqualTo(baseline), "different tag");
Assert.That(BattleSeeds.Derive(7, "x", 43), Is.Not.EqualTo(baseline), "different disc");
}
[Test]
public void Derive_is_always_non_negative()
{
// System.Random tolerates any int, but a non-negative seed keeps parity with prod's
// positive seed values and avoids surprises.
Assert.That(BattleSeeds.Stable(int.MinValue), Is.GreaterThanOrEqualTo(0));
Assert.That(BattleSeeds.Stable(-1), Is.GreaterThanOrEqualTo(0));
}
}

View File

@@ -14,19 +14,19 @@ public class ServerBattleFramesTests
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 906243102, oppoViewerId: 847666884, selfViewerId: 906243102, oppoViewerId: 847666884,
battleId: "b", seed: BattleFrameDefaults.BattleSeed); battleId: "b", seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(env.Uri, Is.EqualTo(NetworkBattleUri.Matched));
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884L)); Assert.That(body.SelfInfo.OppoId, Is.EqualTo(847666884));
Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102L)); Assert.That(body.OppoInfo.OppoId, Is.EqualTo(906243102));
Assert.That(env.Bid, Is.EqualTo("b")); Assert.That(env.Bid, Is.EqualTo("b"));
} }
[Test] [Test]
public void BuildMatched_ContainsThirtyCardSelfDeck() public void BuildMatched_ContainsThirtyCardSelfDeck()
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed); var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), 1, 2, "b", 17_548_138, FixtureCtx().SelfDeckCardIds);
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
Assert.That(body.SelfDeck.Count, Is.EqualTo(30)); Assert.That(body.SelfDeck.Count, Is.EqualTo(30));
} }
@@ -35,7 +35,7 @@ public class ServerBattleFramesTests
public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids() public void BuildMatched_deck_idxs_pair_1to30_with_context_card_ids()
{ {
var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList(); var draftedDeck = Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToList();
var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed); var env = ServerBattleFrames.BuildMatched(FixtureCtx(draftedDeck), FakeOpponentCtx(), 1, 2, "b", 17_548_138, draftedDeck);
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
for (int i = 0; i < 30; i++) for (int i = 0; i < 30; i++)
@@ -56,7 +56,7 @@ public class ServerBattleFramesTests
EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1, EmblemId = "888", DegreeId = "777", FieldId = 42, IsOfficial = 1,
}; };
var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", BattleFrameDefaults.BattleSeed); var env = ServerBattleFrames.BuildMatched(ctx, FakeOpponentCtx(), 1, 2, "b", 17_548_138, ctx.SelfDeckCardIds);
var body = (MatchedBody)env.Body; var body = (MatchedBody)env.Body;
Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN")); Assert.That(body.SelfInfo.CountryCode, Is.EqualTo("JPN"));
@@ -65,35 +65,35 @@ public class ServerBattleFramesTests
Assert.That(body.SelfInfo.EmblemId, Is.EqualTo("888")); Assert.That(body.SelfInfo.EmblemId, Is.EqualTo("888"));
Assert.That(body.SelfInfo.DegreeId, Is.EqualTo("777")); Assert.That(body.SelfInfo.DegreeId, Is.EqualTo("777"));
Assert.That(body.SelfInfo.FieldId, Is.EqualTo(42)); Assert.That(body.SelfInfo.FieldId, Is.EqualTo(42));
Assert.That(body.SelfInfo.IsOfficial, Is.EqualTo(1)); Assert.That(body.SelfInfo.IsOfficial, Is.True);
} }
[Test] [Test]
public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleType() public void BuildBattleStart_HasTurnStateZero_AndUsesContextBattleModeId()
{ {
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 1, turnState: 0); var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 1, turnState: TurnState.First);
var body = (BattleStartBody)env.Body; var body = (BattleStartBody)env.Body;
Assert.That(body.TurnState, Is.EqualTo(0)); Assert.That(body.TurnState, Is.EqualTo(TurnState.First));
Assert.That(body.BattleType, Is.EqualTo(11)); Assert.That(body.BattleModeId, Is.EqualTo(BattleModes.TakeTwo));
} }
[Test] [Test]
public void BuildBattleStart_class_chara_cardMaster_battleType_flow_from_context() public void BuildBattleStart_class_chara_cardMaster_battleModeId_flow_from_context()
{ {
var ctx = FixtureCtx() with var ctx = FixtureCtx() with
{ {
ClassId = "7", CharaId = "5000123", ClassId = CardClass.Havencraft, CharaId = "5000123",
CardMasterName = "card_master_test_v2", CardMasterName = "card_master_test_v2",
BattleType = 42, BattleModeId = 42,
}; };
var env = ServerBattleFrames.BuildBattleStart(ctx, FakeOpponentCtx(), selfViewerId: 1, turnState: 0); var env = ServerBattleFrames.BuildBattleStart(ctx, FakeOpponentCtx(), selfViewerId: 1, turnState: TurnState.First);
var body = (BattleStartBody)env.Body; var body = (BattleStartBody)env.Body;
Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7")); Assert.That(body.SelfInfo.ClassId, Is.EqualTo("7"));
Assert.That(body.SelfInfo.CharaId, Is.EqualTo("5000123")); Assert.That(body.SelfInfo.CharaId, Is.EqualTo("5000123"));
Assert.That(body.SelfInfo.CardMasterName, Is.EqualTo("card_master_test_v2")); Assert.That(body.SelfInfo.CardMasterName, Is.EqualTo("card_master_test_v2"));
Assert.That(body.BattleType, Is.EqualTo(42)); Assert.That(body.BattleModeId, Is.EqualTo(42));
} }
[Test] [Test]
@@ -136,11 +136,11 @@ public class ServerBattleFramesTests
} }
[Test] [Test]
public void BuildReady_IncludesIdxChangeSeedAndSpin_AndUsesGivenHand() public void BuildReady_IncludesGivenIdxChangeSeedAndSpin_AndUsesGivenHand()
{ {
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }); var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000);
var body = (ReadyBody)env.Body; var body = (ReadyBody)env.Body;
Assert.That(body.IdxChangeSeed, Is.EqualTo(771_335_280)); Assert.That(body.IdxChangeSeed, Is.EqualTo(555_000));
Assert.That(body.Spin, Is.EqualTo(243)); Assert.That(body.Spin, Is.EqualTo(243));
Assert.That(body.Self[1].Idx, Is.EqualTo(4)); Assert.That(body.Self[1].Idx, Is.EqualTo(4));
} }
@@ -148,7 +148,7 @@ public class ServerBattleFramesTests
[Test] [Test]
public void BuildReady_two_arg_sets_oppo_to_supplied_hand() public void BuildReady_two_arg_sets_oppo_to_supplied_hand()
{ {
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 }); var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, new long[] { 1, 2, 6 }, idxChangeSeed: 555_000);
var body = (ReadyBody)env.Body; var body = (ReadyBody)env.Body;
Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 })); Assert.That(body.Self.Select(p => p.Idx), Is.EqualTo(new[] { 1, 4, 3 }));
@@ -159,7 +159,7 @@ public class ServerBattleFramesTests
[Test] [Test]
public void BuildReady_one_arg_defaults_oppo_to_InitialHand() public void BuildReady_one_arg_defaults_oppo_to_InitialHand()
{ {
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }); var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 555_000);
var body = (ReadyBody)env.Body; var body = (ReadyBody)env.Body;
Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }), Assert.That(body.Oppo.Select(p => p.Idx), Is.EqualTo(new[] { 1, 2, 3 }),
@@ -168,17 +168,17 @@ public class ServerBattleFramesTests
private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new( private static MatchContext FixtureCtx(IReadOnlyList<long>? deck = null) => new(
SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), SelfDeckCardIds: deck ?? Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
// A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart // A prod-captured opponent MatchContext fixture that the BuildMatched/BuildBattleStart
// helpers read from for the oppo half. // helpers read from for the oppo half.
private static MatchContext FakeOpponentCtx() => new( private static MatchContext FakeOpponentCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015", ClassId: CardClass.Portalcraft, CharaId: "8", CardMasterName: "card_master_node_10015",
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", CountryCode: CountryCodes.Japan, UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0, EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
BattleType: 0); BattleModeId: 0);
} }

View File

@@ -28,7 +28,8 @@ public class TypedBodyWireShapeTests
// with "Value cannot be null. Parameter name: source". The prod wire format // with "Value cannot be null. Parameter name: source". The prod wire format
// emits envelope keys (uri first) before body keys; we must too. // emits envelope keys (uri first) before body keys; we must too.
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: BattleFrameDefaults.BattleSeed); selfViewerId: 1, oppoViewerId: 2, battleId: "b", seed: 17_548_138,
selfDeckOrder: FixtureCtx().SelfDeckCardIds);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal); var uriIdx = json.IndexOf("\"uri\":", StringComparison.Ordinal);
@@ -47,7 +48,7 @@ public class TypedBodyWireShapeTests
{ {
var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(), var env = ServerBattleFrames.BuildMatched(FixtureCtx(), FakeOpponentCtx(),
selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107", selfViewerId: 906243102, oppoViewerId: 847666884, battleId: "597830888107",
seed: BattleFrameDefaults.BattleSeed); seed: 17_548_138, selfDeckOrder: FixtureCtx().SelfDeckCardIds);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var node = JsonNode.Parse(json)!.AsObject(); var node = JsonNode.Parse(json)!.AsObject();
@@ -86,7 +87,7 @@ public class TypedBodyWireShapeTests
[Test] [Test]
public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry() public void BuildBattleStart_SerializesAllWireKeysAndPreservesBattlePointAsymmetry()
{ {
var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, turnState: 0); var env = ServerBattleFrames.BuildBattleStart(FixtureCtx(), FakeOpponentCtx(), selfViewerId: 906243102, turnState: TurnState.First);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var node = JsonNode.Parse(json)!.AsObject(); var node = JsonNode.Parse(json)!.AsObject();
@@ -137,7 +138,7 @@ public class TypedBodyWireShapeTests
[Test] [Test]
public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin() public void BuildReady_SerializesAllFieldsIncludingSeedAndSpin()
{ {
var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }); var env = ServerBattleFrames.BuildReady(new long[] { 1, 4, 3 }, idxChangeSeed: 771_335_280);
var json = MsgEnvelope.ToJson(env); var json = MsgEnvelope.ToJson(env);
var node = JsonNode.Parse(json)!.AsObject(); var node = JsonNode.Parse(json)!.AsObject();
@@ -154,10 +155,10 @@ public class TypedBodyWireShapeTests
/// </summary> /// </summary>
private static MatchContext FixtureCtx() => new( private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
// Prod-captured opponent fixture — 30-card deck and the prod-captured opponent // Prod-captured opponent fixture — 30-card deck and the prod-captured opponent
// cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId, // cosmetics (ClassId/CharaId "8") so the wire bytes asserted below (oppoInfo classId/charaId,
@@ -165,8 +166,8 @@ public class TypedBodyWireShapeTests
// signature change. // signature change.
private static MatchContext FakeOpponentCtx() => new( private static MatchContext FakeOpponentCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 0L).ToList(),
ClassId: "8", CharaId: "8", CardMasterName: "card_master_node_10015", ClassId: CardClass.Portalcraft, CharaId: "8", CardMasterName: "card_master_node_10015",
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", CountryCode: CountryCodes.Japan, UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0, EmblemId: "400001100", DegreeId: "120027", FieldId: 5, IsOfficial: 0,
BattleType: 0); BattleModeId: 0);
} }

View File

@@ -1,6 +1,8 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes; using System.Text.Json.Nodes;
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Bridge;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Protocol.Bodies;
namespace SVSim.UnitTests.BattleNode.Protocol.Bodies; namespace SVSim.UnitTests.BattleNode.Protocol.Bodies;
@@ -12,7 +14,7 @@ public class BattleStartBodyTests
public void Serializes_TopLevelFields_WithCorrectWireKeys() public void Serializes_TopLevelFields_WithCorrectWireKeys()
{ {
var body = new BattleStartBody( var body = new BattleStartBody(
TurnState: 0, BattleType: 11, TurnState: TurnState.First, BattleModeId: BattleModes.TakeTwo,
SelfInfo: new BattleStartSelfInfo("10", "6270", "1", "1", "card_master_node_10015"), SelfInfo: new BattleStartSelfInfo("10", "6270", "1", "1", "card_master_node_10015"),
OppoInfo: new BattleStartOppoInfo("1", "0", 0, "0", "8", "8", "card_master_node_10015")); OppoInfo: new BattleStartOppoInfo("1", "0", 0, "0", "8", "8", "card_master_node_10015"));

View File

@@ -15,11 +15,11 @@ public class MatchedBodyTests
SelfInfo: new MatchedSelfInfo( SelfInfo: new MatchedSelfInfo(
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: "KOR", UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, EmblemId: "701441011", DegreeId: "300003", FieldId: 43,
IsOfficial: 0, OppoId: 847666884L, Seed: 17_548_138L), IsOfficial: false, OppoId: 847666884, Seed: 17_548_138),
OppoInfo: new MatchedOppoInfo( OppoInfo: new MatchedOppoInfo(
CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010", CountryCode: "JPN", UserName: "Opponent", SleeveId: "704141010",
EmblemId: "400001100", DegreeId: "120027", FieldId: 5, EmblemId: "400001100", DegreeId: "120027", FieldId: 5,
IsOfficial: 0, OppoId: 906243102L, Seed: 17_548_138L, OppoDeckCount: 30), IsOfficial: false, OppoId: 906243102, Seed: 17_548_138, OppoDeckCount: 30),
SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) }); SelfDeck: new[] { new DeckCardRef(Idx: 1, CardId: 100011010L) });
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
@@ -32,16 +32,16 @@ public class MatchedBodyTests
Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003")); Assert.That(selfInfo["degreeId"]!.GetValue<string>(), Is.EqualTo("300003"));
Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43)); Assert.That(selfInfo["fieldId"]!.GetValue<int>(), Is.EqualTo(43));
Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0)); Assert.That(selfInfo["isOfficial"]!.GetValue<int>(), Is.EqualTo(0));
Assert.That(selfInfo["oppoId"]!.GetValue<long>(), Is.EqualTo(847666884L)); Assert.That(selfInfo["oppoId"]!.GetValue<int>(), Is.EqualTo(847666884));
Assert.That(selfInfo["seed"]!.GetValue<long>(), Is.EqualTo(17_548_138L)); Assert.That(selfInfo["seed"]!.GetValue<int>(), Is.EqualTo(17_548_138));
} }
[Test] [Test]
public void OppoInfo_HasOppoDeckCount_OnTheWire() public void OppoInfo_HasOppoDeckCount_OnTheWire()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L, OppoDeckCount: 30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1, OppoDeckCount: 30),
SelfDeck: System.Array.Empty<DeckCardRef>()); SelfDeck: System.Array.Empty<DeckCardRef>());
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
@@ -54,8 +54,8 @@ public class MatchedBodyTests
public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire() public void SelfInfo_DoesNotHaveOppoDeckCount_OnTheWire()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L,30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
SelfDeck: System.Array.Empty<DeckCardRef>()); SelfDeck: System.Array.Empty<DeckCardRef>());
var node = (JsonObject)JsonSerializer.SerializeToNode(body)!; var node = (JsonObject)JsonSerializer.SerializeToNode(body)!;
@@ -68,8 +68,8 @@ public class MatchedBodyTests
public void ResultCode_DefaultsToOne_OnConstruction() public void ResultCode_DefaultsToOne_OnConstruction()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L,30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
SelfDeck: System.Array.Empty<DeckCardRef>()); SelfDeck: System.Array.Empty<DeckCardRef>());
Assert.That(body.ResultCode, Is.EqualTo(1)); Assert.That(body.ResultCode, Is.EqualTo(1));
@@ -81,8 +81,8 @@ public class MatchedBodyTests
public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys() public void SelfDeck_SerializesAsArray_WithIdxAndCardIdKeys()
{ {
var body = new MatchedBody( var body = new MatchedBody(
SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,0,1L,1L), SelfInfo: new MatchedSelfInfo("KOR","P","s","e","d",0,false,1,1),
OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,0,1L,1L,30), OppoInfo: new MatchedOppoInfo("JPN","O","s","e","d",0,false,1,1,30),
SelfDeck: new[] SelfDeck: new[]
{ {
new DeckCardRef(Idx: 1, CardId: 100011010L), new DeckCardRef(Idx: 1, CardId: 100011010L),

View File

@@ -35,7 +35,7 @@ public class MsgEnvelopeTests
ViewerId: 906243102, ViewerId: 906243102,
Uuid: "udid-1234", Uuid: "udid-1234",
Bid: "597830888107", Bid: "597830888107",
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.General, Cat: EmitCategory.General,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
@@ -60,7 +60,7 @@ public class MsgEnvelopeTests
ViewerId: 1, ViewerId: 1,
Uuid: "u", Uuid: "u",
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: 5, PlaySeq: 5,
@@ -92,7 +92,7 @@ public class MsgEnvelopeTests
ViewerId: 1, ViewerId: 1,
Uuid: "u", Uuid: "u",
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,
@@ -110,7 +110,7 @@ public class MsgEnvelopeTests
ViewerId: 1, ViewerId: 1,
Uuid: "u", Uuid: "u",
Bid: null, Bid: null,
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: null, PubSeq: null,
PlaySeq: null, PlaySeq: null,

View File

@@ -20,7 +20,7 @@ public class MsgPayloadCodecTests
ViewerId: 906243102, ViewerId: 906243102,
Uuid: "udid", Uuid: "udid",
Bid: "1234", Bid: "1234",
Try: 0, RetryAttempt: 0,
Cat: EmitCategory.Battle, Cat: EmitCategory.Battle,
PubSeq: 3, PubSeq: 3,
PlaySeq: null, PlaySeq: null,

View File

@@ -0,0 +1,51 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using NUnit.Framework;
using SVSim.BattleNode.Protocol;
namespace SVSim.UnitTests.BattleNode.Protocol;
[TestFixture]
public class NumericBoolJsonConverterTests
{
private sealed record Probe(
[property: JsonPropertyName("flag")]
[property: JsonConverter(typeof(NumericBoolJsonConverter))]
bool Flag,
[property: JsonPropertyName("opt")]
[property: JsonConverter(typeof(NumericBoolJsonConverter))]
bool? Opt = null);
private static readonly JsonSerializerOptions Options = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
[Test]
public void Writes_true_as_numeric_1_and_false_as_numeric_0()
{
var node = JsonSerializer.SerializeToElement(new Probe(Flag: true), Options);
Assert.That(node.GetProperty("flag").ValueKind, Is.EqualTo(JsonValueKind.Number));
Assert.That(node.GetProperty("flag").GetInt32(), Is.EqualTo(1));
var falseNode = JsonSerializer.SerializeToElement(new Probe(Flag: false), Options);
Assert.That(falseNode.GetProperty("flag").GetInt32(), Is.EqualTo(0));
}
[Test]
public void Reads_numeric_0_and_1_back_to_bool()
{
Assert.That(JsonSerializer.Deserialize<Probe>("{\"flag\":1}", Options)!.Flag, Is.True);
Assert.That(JsonSerializer.Deserialize<Probe>("{\"flag\":0}", Options)!.Flag, Is.False);
}
[Test]
public void Nullable_true_emits_1_and_null_is_omitted()
{
var present = JsonSerializer.SerializeToElement(new Probe(Flag: false, Opt: true), Options);
Assert.That(present.GetProperty("opt").GetInt32(), Is.EqualTo(1));
var absent = JsonSerializer.SerializeToElement(new Probe(Flag: false, Opt: null), Options);
Assert.That(absent.TryGetProperty("opt", out _), Is.False, "null bool? must be omitted, not emitted");
}
}

View File

@@ -8,7 +8,7 @@ namespace SVSim.UnitTests.BattleNode.Reliability;
public class OutboundSequencerTests public class OutboundSequencerTests
{ {
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) => private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0, Cat: EmitCategory.Battle,
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>())); PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
[Test] [Test]

View File

@@ -0,0 +1,124 @@
using System.Linq;
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;
namespace SVSim.UnitTests.BattleNode.Sessions;
/// <summary>
/// In PvP a <see cref="BattleSession"/> subscribes to BOTH participants' FrameEmitted, and each
/// RealParticipant raises it from its own WebSocket read loop — i.e. two threads. The dispatch path
/// (ComputeFrames + the relay PushAsync calls) mutates shared, non-thread-safe session state, so it
/// must be serialized per session. This drives the two participants' dispatch concurrently and asserts
/// no two dispatches ever overlap.
/// </summary>
[TestFixture]
public class BattleSessionDispatchConcurrencyTests
{
[Test]
public async Task Concurrent_dispatch_from_both_participants_is_serialized()
{
var detector = new ConcurrencyDetector();
var a = new ProbeParticipant(1001, CtxA(), detector);
var b = new ProbeParticipant(2002, CtxB(), detector);
var s = new BattleSession("bid-conc", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
// Reach AfterReady single-threaded (ComputeFrames returns routes but never calls PushAsync,
// so the detector is untouched during setup).
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
detector.Arm();
// Fire a gameplay frame from each side at the same instant. A's TurnStart routes to B.PushAsync
// and B's to A.PushAsync, so both dispatches run their PushAsync concurrently unless the session
// serializes them.
using var gate = new ManualResetEventSlim(false);
var ta = Task.Run(async () => { gate.Wait(); await a.RaiseAsync(Env(NetworkBattleUri.TurnStart)); });
var tb = Task.Run(async () => { gate.Wait(); await b.RaiseAsync(Env(NetworkBattleUri.TurnStart)); });
gate.Set();
await Task.WhenAll(ta, tb);
Assert.That(detector.MaxConcurrent, Is.EqualTo(1),
"Two read-loop threads dispatched into shared session state concurrently; " +
"HandleFrameAsync must serialize dispatch per session.");
}
private static void DriveToAfterReady(BattleSession s, ProbeParticipant p)
{
s.ComputeFrames(p, Env(NetworkBattleUri.InitNetwork));
s.ComputeFrames(p, Env(NetworkBattleUri.InitBattle));
s.ComputeFrames(p, Env(NetworkBattleUri.Loaded));
s.ComputeFrames(p, Env(NetworkBattleUri.Swap));
}
private static MsgEnvelope Env(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
Body: new RawBody(new Dictionary<string, object?>()));
private static MatchContext CtxA() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: CardClass.Runecraft, CharaId: "3", CardMasterName: "card_master_node_10015",
CountryCode: CountryCodes.Korea, UserName: "PlayerA", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
private static MatchContext CtxB() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(),
ClassId: CardClass.Shadowcraft, CharaId: "5", CardMasterName: "card_master_node_10015",
CountryCode: CountryCodes.Japan, UserName: "PlayerB", SleeveId: "3000022",
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
/// <summary>Tracks the peak number of dispatches in flight at once. Records the count under a
/// short lock, then holds (outside the lock) to widen the overlap window so a serialization bug
/// is observed deterministically rather than relied on to interleave by chance.</summary>
private sealed class ConcurrencyDetector
{
private const int WidenMs = 50;
private readonly object _lock = new();
private int _current;
private volatile bool _armed;
public int MaxConcurrent { get; private set; }
public void Arm() => _armed = true;
public async Task EnterAsync()
{
if (!_armed) return;
lock (_lock)
{
_current++;
if (_current > MaxConcurrent) MaxConcurrent = _current;
}
await Task.Delay(WidenMs);
lock (_lock) { _current--; }
}
}
private sealed class ProbeParticipant : IBattleParticipant, IHasHandshakePhase
{
private readonly ConcurrencyDetector _detector;
public long ViewerId { get; }
public MatchContext Context { get; }
public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public ProbeParticipant(long viewerId, MatchContext context, ConcurrencyDetector detector)
{
ViewerId = viewerId;
Context = context;
_detector = detector;
}
public Task RaiseAsync(MsgEnvelope env) =>
FrameEmitted?.Invoke(env, CancellationToken.None) ?? Task.CompletedTask;
public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => _detector.EnterAsync();
public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View File

@@ -21,7 +21,7 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Loaded));
var bs = (BattleStartBody)routes[0].Frame.Body; var bs = (BattleStartBody)routes[0].Frame.Body;
Assert.That(bs.TurnState, Is.EqualTo(0), "A (first arriver) goes first."); Assert.That(bs.TurnState, Is.EqualTo(TurnState.First), "A (first arriver) goes first.");
} }
[Test] [Test]
@@ -33,7 +33,7 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded)); var routes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Loaded));
var bs = (BattleStartBody)routes[0].Frame.Body; var bs = (BattleStartBody)routes[0].Frame.Body;
Assert.That(bs.TurnState, Is.EqualTo(1), "B (second arriver) goes second."); Assert.That(bs.TurnState, Is.EqualTo(TurnState.Second), "B (second arriver) goes second.");
} }
[Test] [Test]
@@ -44,19 +44,19 @@ public class BattleSessionDispatchTests
var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance); var s = new BattleSession("bid-1", BattleType.Pvp, a, b, NullLogger<BattleSession>.Instance);
// A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set). // A is AwaitingInitNetwork; B is AwaitingInitBattle (manually set).
b.Phase = BattleSessionPhase.AwaitingInitBattle; b.Phase = HandshakePhase.AwaitingInitBattle;
// A's InitNetwork should ack (matches A's phase). // A's InitNetwork should ack (matches A's phase).
var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork)); var routesA = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
Assert.That(routesA.Count, Is.EqualTo(1)); Assert.That(routesA.Count, Is.EqualTo(1));
Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(routesA[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitBattle));
// B's InitBattle should produce Matched (matches B's phase, set above). // B's InitBattle should produce Matched (matches B's phase, set above).
var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle)); var routesB = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.InitBattle));
Assert.That(routesB.Count, Is.EqualTo(1)); Assert.That(routesB.Count, Is.EqualTo(1));
Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched)); Assert.That(routesB[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Matched));
Assert.That(b.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); Assert.That(b.Phase, Is.EqualTo(HandshakePhase.AwaitingLoaded));
} }
[Test] [Test]
@@ -66,7 +66,7 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap));
Assert.That(routes, Is.Empty); Assert.That(routes, Is.Empty);
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitNetwork)); Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitNetwork));
} }
[Test] [Test]
@@ -91,6 +91,41 @@ public class BattleSessionDispatchTests
"Both sides must see the same seed."); "Both sides must see the same seed.");
} }
[Test]
public void Pvp_Matched_seed_derives_from_master_via_BattleSeeds_Stable()
{
var (s, a, _) = NewPvpSession();
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitNetwork));
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.InitBattle));
var body = (MatchedBody)routes[0].Frame.Body;
Assert.That(body.SelfInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
Assert.That(body.OppoInfo.Seed, Is.EqualTo(BattleSeeds.Stable(s.MasterSeed)));
}
[Test]
public void Pvp_Ready_idxChangeSeed_derives_from_master_and_recipient_viewer()
{
var (s, a, b) = NewPvpSession();
// Both sides must complete the handshake before either can swap; then a swaps, then b's
// swap releases Ready to BOTH (mirrors Pvp_Swap_from_both_releases_Ready).
foreach (var p in new[] { a, b })
{
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitNetwork));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.InitBattle));
s.ComputeFrames(p, NewEnvelope(NetworkBattleUri.Loaded));
}
s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Swap)); // a swaps first
var bRoutes = s.ComputeFrames(b, NewEnvelope(NetworkBattleUri.Swap)); // b releases both Readys
var readyToA = bRoutes.Single(r => ReferenceEquals(r.Target, a) && r.Frame.Uri == NetworkBattleUri.Ready);
var readyToB = bRoutes.Single(r => ReferenceEquals(r.Target, b) && r.Frame.Uri == NetworkBattleUri.Ready);
Assert.That(((ReadyBody)readyToA.Frame.Body).IdxChangeSeed,
Is.EqualTo(BattleSeeds.IdxChange(s.MasterSeed, a.ViewerId)));
Assert.That(((ReadyBody)readyToB.Frame.Body).IdxChangeSeed,
Is.EqualTo(BattleSeeds.IdxChange(s.MasterSeed, b.ViewerId)));
}
[Test] [Test]
public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only() public void Pvp_InitBattle_from_B_pushes_Matched_with_A_oppoInfo_to_B_only()
{ {
@@ -134,7 +169,7 @@ public class BattleSessionDispatchTests
Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }), Assert.That(routes.Select(r => r.Frame.Uri), Is.EqualTo(new[] { NetworkBattleUri.Swap }),
"Ready is withheld until BOTH sides have mulliganed."); "Ready is withheld until BOTH sides have mulliganed.");
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady), Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AfterReady),
"Phase advances on Swap even though Ready is withheld."); "Phase advances on Swap even though Ready is withheld.");
} }
@@ -246,7 +281,7 @@ public class BattleSessionDispatchTests
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1)); Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8)); Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0)); Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(CardOwner.Opponent));
} }
[Test] [Test]
@@ -281,7 +316,7 @@ public class BattleSessionDispatchTests
Assert.That(pb.UList[0].IdxList, Is.EqualTo(new[] { 16, 22 })); Assert.That(pb.UList[0].IdxList, Is.EqualTo(new[] { 16, 22 }));
Assert.That(pb.UList[0].From, Is.EqualTo(0)); Assert.That(pb.UList[0].From, Is.EqualTo(0));
Assert.That(pb.UList[0].To, Is.EqualTo(10)); Assert.That(pb.UList[0].To, Is.EqualTo(10));
Assert.That(pb.UList[0].IsSelf, Is.EqualTo(1)); Assert.That(pb.UList[0].IsSelf, Is.EqualTo(CardOwner.Self));
Assert.That(pb.UList[0].Skill, Is.EqualTo("37|36|0")); Assert.That(pb.UList[0].Skill, Is.EqualTo("37|36|0"));
} }
@@ -618,7 +653,7 @@ public class BattleSessionDispatchTests
Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(100_011_010L), "generating deck card revealed"); Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(100_011_010L), "generating deck card revealed");
// keyAction forwarded as {type,cardId}; selectCard stripped for the hidden choice. // keyAction forwarded as {type,cardId}; selectCard stripped for the hidden choice.
Assert.That(pb.KeyAction, Is.Not.Null); Assert.That(pb.KeyAction, Is.Not.Null);
Assert.That(pb.KeyAction!.Single().Type, Is.EqualTo(1)); Assert.That(pb.KeyAction!.Single().Type, Is.EqualTo(KeyActionType.Choice));
Assert.That(pb.KeyAction.Single().CardId, Is.EqualTo(100_011_010L)); Assert.That(pb.KeyAction.Single().CardId, Is.EqualTo(100_011_010L));
Assert.That(pb.KeyAction.Single().SelectCard, Is.Null, "the pick stays hidden for open:0"); Assert.That(pb.KeyAction.Single().SelectCard, Is.Null, "the pick stays hidden for open:0");
} }
@@ -676,7 +711,7 @@ public class BattleSessionDispatchTests
{ {
var (s, a, b) = NewPvpSession(); var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a); DriveToAfterReady(s, a);
// B not AfterReady → not BothAfterReady. // B not AfterReady → not BothSidesAfterReady.
var body = MoveOrderList(3, 10, 20); var body = MoveOrderList(3, 10, 20);
body["playIdx"] = 3L; body["type"] = 30L; body["playIdx"] = 3L; body["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body)); var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
@@ -684,7 +719,7 @@ public class BattleSessionDispatchTests
} }
[Test] [Test]
public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed() public void Pvp_Echo_from_A_in_BothSidesAfterReady_is_consumed_not_relayed()
{ {
var (s, a, b) = NewPvpSession(); var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a); DriveToAfterReady(s, a);
@@ -747,7 +782,7 @@ public class BattleSessionDispatchTests
} }
[Test] [Test]
public void Pvp_JudgeResult_from_A_in_BothAfterReady_forwards_to_B() public void Pvp_JudgeResult_from_A_in_BothSidesAfterReady_forwards_to_B()
{ {
var (s, a, b) = NewPvpSession(); var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a); DriveToAfterReady(s, a);
@@ -772,7 +807,7 @@ public class BattleSessionDispatchTests
Assert.That(routes[0].Target, Is.SameAs(b)); Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body; var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
Assert.That(body.TurnState, Is.EqualTo(0)); Assert.That(body.TurnState, Is.EqualTo(TurnState.First));
} }
[Test] [Test]
@@ -798,7 +833,7 @@ public class BattleSessionDispatchTests
Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(routes[2].Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose)); Assert.That(((BattleFinishBody)routes[2].Frame.Body).Result, Is.EqualTo(BattleResult.LifeLose));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
} }
[Test] [Test]
@@ -829,9 +864,9 @@ public class BattleSessionDispatchTests
Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose)); Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose));
Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish));
Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin)); Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin));
Assert.That(aRoute.NoStock, Is.True); Assert.That(aRoute.Stock, Is.EqualTo(Stock.Bypass));
Assert.That(bRoute.NoStock, Is.True); Assert.That(bRoute.Stock, Is.EqualTo(Stock.Bypass));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
} }
[Test] [Test]
@@ -844,7 +879,7 @@ public class BattleSessionDispatchTests
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill)); var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Kill));
Assert.That(routes.Count, Is.EqualTo(2)); Assert.That(routes.Count, Is.EqualTo(2));
Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); Assert.That(s.Lifecycle, Is.EqualTo(SessionLifecycle.Terminal));
} }
private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession() private static (BattleSession, FakeRealParticipant, FakeParticipant) NewBotSession()
@@ -857,9 +892,9 @@ public class BattleSessionDispatchTests
private static MatchContext NoOpBotContext() => new( private static MatchContext NoOpBotContext() => new(
SelfDeckCardIds: Array.Empty<long>(), SelfDeckCardIds: Array.Empty<long>(),
ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015", ClassId: CardClass.None, CharaId: "0", CardMasterName: "card_master_node_10015",
CountryCode: "", UserName: "Bot", SleeveId: "0", CountryCode: "", UserName: "Bot", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 0); EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: 0);
[Test] [Test]
public void Bot_InitNetwork_acks_to_sender() public void Bot_InitNetwork_acks_to_sender()
@@ -870,7 +905,7 @@ public class BattleSessionDispatchTests
Assert.That(routes.Count, Is.EqualTo(1)); Assert.That(routes.Count, Is.EqualTo(1));
Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork)); Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitNetwork));
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingInitBattle)); Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingInitBattle));
} }
[Test] [Test]
@@ -888,7 +923,7 @@ public class BattleSessionDispatchTests
Assert.That(routes[0].Target, Is.SameAs(a)); Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle), Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.InitBattle),
"Expected an ack envelope for InitBattle, NOT a Matched envelope."); "Expected an ack envelope for InitBattle, NOT a Matched envelope.");
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingLoaded)); Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingLoaded));
} }
[Test] [Test]
@@ -904,7 +939,7 @@ public class BattleSessionDispatchTests
// handler at Matching.cs:417 → SetNetworkInfo overwrites it with our // handler at Matching.cs:417 → SetNetworkInfo overwrites it with our
// placeholder NoOpBotParticipant.Context zeros). // placeholder NoOpBotParticipant.Context zeros).
Assert.That(routes, Is.Empty, "Bot Loaded is silent."); Assert.That(routes, Is.Empty, "Bot Loaded is silent.");
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AwaitingSwap), Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AwaitingSwap),
"Phase still advances even though there are no outbound routes."); "Phase still advances even though there are no outbound routes.");
} }
@@ -921,7 +956,7 @@ public class BattleSessionDispatchTests
Assert.That(routes.Select(r => r.Frame.Uri), Assert.That(routes.Select(r => r.Frame.Uri),
Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready })); Is.EqualTo(new[] { NetworkBattleUri.Swap, NetworkBattleUri.Ready }));
Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True); Assert.That(routes.All(r => ReferenceEquals(r.Target, a)), Is.True);
Assert.That(a.Phase, Is.EqualTo(BattleSessionPhase.AfterReady)); Assert.That(a.Phase, Is.EqualTo(HandshakePhase.AfterReady));
} }
[Test] [Test]
@@ -1013,32 +1048,32 @@ public class BattleSessionDispatchTests
private static MatchContext PlayerACtx() => new( private static MatchContext PlayerACtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "3", CharaId: "3", CardMasterName: "card_master_node_10015", ClassId: CardClass.Runecraft, CharaId: "3", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "PlayerA", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "PlayerA", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
private static MatchContext PlayerBCtx() => new( private static MatchContext PlayerBCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 200_011_010L).ToList(),
ClassId: "5", CharaId: "5", CardMasterName: "card_master_node_10015", ClassId: CardClass.Shadowcraft, CharaId: "5", CardMasterName: "card_master_node_10015",
CountryCode: "JPN", UserName: "PlayerB", SleeveId: "3000022", CountryCode: CountryCodes.Japan, UserName: "PlayerB", SleeveId: "3000022",
EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0, EmblemId: "701441022", DegreeId: "300004", FieldId: 44, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
private static MatchContext FixtureCtx() => new( private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
Body: new RawBody(new Dictionary<string, object?>())); Body: new RawBody(new Dictionary<string, object?>()));
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) => private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body)); Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new() private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
@@ -1064,7 +1099,7 @@ public class BattleSessionDispatchTests
public MatchContext Context { get; } public MatchContext Context { get; }
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted; public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; } public FakeParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask; public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => Task.CompletedTask;
public Task RunAsync(CancellationToken ct) => Task.CompletedTask; public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
@@ -1079,10 +1114,10 @@ public class BattleSessionDispatchTests
{ {
public long ViewerId { get; } public long ViewerId { get; }
public MatchContext Context { get; } public MatchContext Context { get; }
public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; public HandshakePhase Phase { get; set; } = HandshakePhase.AwaitingInitNetwork;
public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted; public event Func<MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; } public FakeRealParticipant(long viewerId, MatchContext context) { ViewerId = viewerId; Context = context; }
public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => Task.CompletedTask; public Task PushAsync(MsgEnvelope env, Stock stock, CancellationToken ct) => Task.CompletedTask;
public Task RunAsync(CancellationToken ct) => Task.CompletedTask; public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;

View File

@@ -14,7 +14,7 @@ public class BattleSessionStateTests
public MatchContext Context { get; } public MatchContext Context { get; }
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted; public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? FrameEmitted;
public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; } public StubParticipant(long id, MatchContext ctx) { ViewerId = id; Context = ctx; }
public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, bool n, CancellationToken c) => Task.CompletedTask; public Task PushAsync(SVSim.BattleNode.Protocol.MsgEnvelope e, Stock n, CancellationToken c) => Task.CompletedTask;
public Task RunAsync(CancellationToken c) => Task.CompletedTask; public Task RunAsync(CancellationToken c) => Task.CompletedTask;
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask; public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
@@ -22,22 +22,26 @@ public class BattleSessionStateTests
} }
private static MatchContext Ctx(params long[] deck) => new( private static MatchContext Ctx(params long[] deck) => new(
SelfDeckCardIds: deck, ClassId: "1", CharaId: "1", CardMasterName: "cm", SelfDeckCardIds: deck, ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "cm",
CountryCode: "KOR", UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0", CountryCode: CountryCodes.Korea, UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
FieldId: 0, IsOfficial: 0, BattleType: 11); FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
[Test] [Test]
public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds() public void GetOrSeedDeckMap_maps_idx_1based_to_the_shuffled_order()
{ {
var state = new BattleSessionState(); // The map seeds from GetShuffledDeck, not raw build order. idx (i+1) -> shuffledDeck[i],
// and the set of cardIds is unchanged (1..3 present, 4 absent).
var state = new BattleSessionState(masterSeed: 12345);
var p = new StubParticipant(1, Ctx(900L, 901L, 902L)); var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
var shuffled = state.GetShuffledDeck(p);
var map = state.GetOrSeedDeckMap(p); var map = state.GetOrSeedDeckMap(p);
Assert.That(map[1], Is.EqualTo(900L)); Assert.That(map[1], Is.EqualTo(shuffled[0]));
Assert.That(map[2], Is.EqualTo(901L)); Assert.That(map[2], Is.EqualTo(shuffled[1]));
Assert.That(map[3], Is.EqualTo(902L)); Assert.That(map[3], Is.EqualTo(shuffled[2]));
Assert.That(map.ContainsKey(4), Is.False); Assert.That(map.ContainsKey(4), Is.False);
Assert.That(new[] { map[1], map[2], map[3] }, Is.EquivalentTo(new[] { 900L, 901L, 902L }));
} }
[Test] [Test]
@@ -47,4 +51,43 @@ public class BattleSessionStateTests
var p = new StubParticipant(1, Ctx(900L)); var p = new StubParticipant(1, Ctx(900L));
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p))); Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
} }
[Test]
public void GetShuffledDeck_is_a_permutation_of_the_input()
{
var state = new BattleSessionState(masterSeed: 12345);
var p = new StubParticipant(1001, Ctx(DistinctDeck()));
Assert.That(state.GetShuffledDeck(p), Is.EquivalentTo(DistinctDeck()),
"same multiset of cards, just reordered");
}
[Test]
public void GetShuffledDeck_actually_reorders_a_distinct_deck()
{
var state = new BattleSessionState(masterSeed: 12345);
var p = new StubParticipant(1001, Ctx(DistinctDeck()));
Assert.That(state.GetShuffledDeck(p), Is.Not.EqualTo(DistinctDeck()),
"a 30-card distinct deck should not survive the shuffle in original order");
}
[Test]
public void GetShuffledDeck_is_deterministic_for_same_master_seed_and_viewer()
{
var a = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
var b = new BattleSessionState(masterSeed: 777).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
Assert.That(a, Is.EqualTo(b));
}
[Test]
public void GetShuffledDeck_differs_across_master_seeds()
{
var a = new BattleSessionState(masterSeed: 1).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
var b = new BattleSessionState(masterSeed: 2).GetShuffledDeck(new StubParticipant(1001, Ctx(DistinctDeck())));
Assert.That(a, Is.Not.EqualTo(b));
}
private static long[] DistinctDeck() =>
Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToArray();
} }

View File

@@ -47,12 +47,12 @@ public class BattleSessionTerminateCascadeTests
} }
private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) => private static MsgEnvelope MakeEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, Cat: EmitCategory.Battle, new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0, Cat: EmitCategory.Battle,
PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>())); PubSeq: null, PlaySeq: null, Body: new RawBody(new Dictionary<string, object?>()));
private static MatchContext MakeFakeContext() => new( private static MatchContext MakeFakeContext() => new(
SelfDeckCardIds: Array.Empty<long>(), SelfDeckCardIds: Array.Empty<long>(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "JP", UserName: "Test", SleeveId: "0", CountryCode: "JP", UserName: "Test", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11); EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
} }

View File

@@ -7,45 +7,46 @@ namespace SVSim.UnitTests.BattleNode.Sessions;
[TestFixture] [TestFixture]
public class InMemoryBattleSessionStoreTests public class InMemoryBattleSessionStoreTests
{ {
private InMemoryBattleSessionStore _store = null!;
[SetUp] public void Setup() => _store = new InMemoryBattleSessionStore();
[Test] [Test]
public void RegisterThenGet_ReturnsRegisteredBattle() public void TryRegisterThenGet_ReturnsRegisteredBattle()
{ {
var store = new InMemoryBattleSessionStore();
var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null); var battle = new PendingBattle("bid-1", BattleType.Bot, new BattlePlayer(906243102, FixtureCtx()), null);
_store.RegisterPending(battle); Assert.That(store.TryRegisterPending(battle), Is.True);
Assert.That(_store.TryGetPending("bid-1"), Is.EqualTo(battle)); Assert.That(store.TryGetPending("bid-1"), Is.EqualTo(battle));
} }
[Test] [Test]
public void Get_UnknownBattleId_ReturnsNull() public void Get_UnknownBattleId_ReturnsNull()
{ {
Assert.That(_store.TryGetPending("nope"), Is.Null); var store = new InMemoryBattleSessionStore();
Assert.That(store.TryGetPending("nope"), Is.Null);
} }
[Test] [Test]
public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent() public void Remove_ReturnsTrueWhenPresent_FalseWhenAbsent()
{ {
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null)); var store = new InMemoryBattleSessionStore();
Assert.That(_store.RemovePending("bid"), Is.True); store.TryRegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
Assert.That(_store.RemovePending("bid"), Is.False); Assert.That(store.RemovePending("bid"), Is.True);
Assert.That(store.RemovePending("bid"), Is.False);
} }
[Test] [Test]
public void Register_DuplicateBattleId_OverwritesPrior() public void TryRegister_DuplicateBattleId_ReturnsFalseAndPreservesOriginal()
{ {
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null)); var store = new InMemoryBattleSessionStore();
_store.RegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null)); store.TryRegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(1, FixtureCtx()), null));
Assert.That(_store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(2)); var second = store.TryRegisterPending(new PendingBattle("bid", BattleType.Bot, new BattlePlayer(2, FixtureCtx()), null));
Assert.That(second, Is.False);
Assert.That(store.TryGetPending("bid")!.P1.ViewerId, Is.EqualTo(1));
} }
private static MatchContext FixtureCtx() => new( private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(i => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
} }

View File

@@ -1,4 +1,5 @@
using NUnit.Framework; using NUnit.Framework;
using SVSim.BattleNode.Protocol;
using SVSim.BattleNode.Protocol.Bodies; using SVSim.BattleNode.Protocol.Bodies;
using SVSim.BattleNode.Sessions.Dispatch; using SVSim.BattleNode.Sessions.Dispatch;
@@ -103,7 +104,7 @@ public class KnownListBuilderTests
Assert.That(renamed, Is.Not.Null); Assert.That(renamed, Is.Not.Null);
Assert.That(renamed!.Count, Is.EqualTo(1)); Assert.That(renamed!.Count, Is.EqualTo(1));
Assert.That(renamed[0].TargetIdx, Is.EqualTo(8)); Assert.That(renamed[0].TargetIdx, Is.EqualTo(8));
Assert.That(renamed[0].IsSelf, Is.EqualTo(0)); Assert.That(renamed[0].IsSelf, Is.EqualTo(CardOwner.Opponent));
} }
[Test] [Test]
@@ -130,7 +131,7 @@ public class KnownListBuilderTests
var orderList = new List<object?> { AddOp(new[] { 31L, 32L }, 900111010L) }; var orderList = new List<object?> { AddOp(new[] { 31L, 32L }, 900111010L) };
var mined = KnownListBuilder.MineAddOps(orderList).ToList(); var mined = KnownListBuilder.MineAddOps(orderList).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900111010L, 1) })); Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Self), new MinedToken(32, 900111010L, CardOwner.Self) }));
} }
[Test] [Test]
@@ -141,7 +142,7 @@ public class KnownListBuilderTests
// it; the caller routes it into the OTHER side's map. // it; the caller routes it into the OTHER side's map.
var orderList = new List<object?> { AddOp(new[] { 31L }, 900111010L, isSelf: 0) }; var orderList = new List<object?> { AddOp(new[] { 31L }, 900111010L, isSelf: 0) };
Assert.That(KnownListBuilder.MineAddOps(orderList), Assert.That(KnownListBuilder.MineAddOps(orderList),
Is.EquivalentTo(new[] { (31, 900111010L, 0) })); Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Opponent) }));
} }
[Test] [Test]
@@ -204,7 +205,7 @@ public class KnownListBuilderTests
AddOp(new[] { 32L }, 900811090L), AddOp(new[] { 32L }, 900811090L),
}; };
var mined = KnownListBuilder.MineAddOps(orderList).ToList(); var mined = KnownListBuilder.MineAddOps(orderList).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900811090L, 1) })); Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Self), new MinedToken(32, 900811090L, CardOwner.Self) }));
} }
// A choice/Discover add op as it arrives in a RawBody: candidates-only (no concrete cardId — // A choice/Discover add op as it arrives in a RawBody: candidates-only (no concrete cardId —
@@ -247,7 +248,7 @@ public class KnownListBuilderTests
var keyAction = KeyActionChoice(generatingCardId: 810014030L, chosen: new[] { 810041260L }, open: 0); var keyAction = KeyActionChoice(generatingCardId: 810014030L, chosen: new[] { 810041260L }, open: 0);
Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction),
Is.EquivalentTo(new[] { (46, 810041260L, 1) })); Is.EquivalentTo(new[] { new MinedToken(46, 810041260L, CardOwner.Self) }));
} }
[Test] [Test]
@@ -259,7 +260,7 @@ public class KnownListBuilderTests
var keyAction = KeyActionChoice(810014030L, new[] { 101041020L }, open: 0); var keyAction = KeyActionChoice(810014030L, new[] { 101041020L }, open: 0);
Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction),
Is.EquivalentTo(new[] { (46, 101041020L, 0) })); Is.EquivalentTo(new[] { new MinedToken(46, 101041020L, CardOwner.Opponent) }));
} }
[Test] [Test]
@@ -302,7 +303,7 @@ public class KnownListBuilderTests
Assert.That(stripped, Is.Not.Null); Assert.That(stripped, Is.Not.Null);
Assert.That(stripped!.Count, Is.EqualTo(1)); Assert.That(stripped!.Count, Is.EqualTo(1));
Assert.That(stripped[0].Type, Is.EqualTo(1)); Assert.That(stripped[0].Type, Is.EqualTo(KeyActionType.Choice));
Assert.That(stripped[0].CardId, Is.EqualTo(810014030L)); Assert.That(stripped[0].CardId, Is.EqualTo(810014030L));
Assert.That(stripped[0].SelectCard, Is.Null); Assert.That(stripped[0].SelectCard, Is.Null);
} }
@@ -316,7 +317,7 @@ public class KnownListBuilderTests
Assert.That(stripped![0].SelectCard, Is.Not.Null); Assert.That(stripped![0].SelectCard, Is.Not.Null);
Assert.That(stripped[0].SelectCard!.CardId, Is.EqualTo(new[] { 810041260L })); Assert.That(stripped[0].SelectCard!.CardId, Is.EqualTo(new[] { 810041260L }));
Assert.That(stripped[0].SelectCard.Open, Is.EqualTo(1)); Assert.That(stripped[0].SelectCard.Open, Is.EqualTo(ChoiceVisibility.Open));
} }
[Test] [Test]
@@ -356,7 +357,7 @@ public class KnownListBuilderTests
var selfMap = new Dictionary<int, long> { [5] = 100_011_010L }; var selfMap = new Dictionary<int, long> { [5] = 100_011_010L };
var otherMap = new Dictionary<int, long>(); var otherMap = new Dictionary<int, long>();
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList(); var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (31, 100_011_010L, 1) })); Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 100_011_010L, CardOwner.Self) }));
} }
[Test] [Test]
@@ -368,7 +369,7 @@ public class KnownListBuilderTests
var selfMap = new Dictionary<int, long>(); var selfMap = new Dictionary<int, long>();
var otherMap = new Dictionary<int, long> { [21] = 900_841_330L }; var otherMap = new Dictionary<int, long> { [21] = 900_841_330L };
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList(); var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (49, 900_841_330L, 0) })); Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(49, 900_841_330L, CardOwner.Opponent) }));
} }
[Test] [Test]
@@ -421,7 +422,7 @@ public class KnownListBuilderTests
var orderList = new List<object?> { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) }; var orderList = new List<object?> { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) };
var selfMap = new Dictionary<int, long> { [5] = 700L }; var selfMap = new Dictionary<int, long> { [5] = 700L };
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary<int, long>()).ToList(); var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary<int, long>()).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (31, 700L, 1), (32, 700L, 1) })); Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 700L, CardOwner.Self), new MinedToken(32, 700L, CardOwner.Self) }));
} }
// A uList entry as it arrives in a RawBody. Minimal = the 5 always-present fields // A uList entry as it arrives in a RawBody. Minimal = the 5 always-present fields
@@ -447,7 +448,7 @@ public class KnownListBuilderTests
Assert.That(e.IdxList, Is.EqualTo(new[] { 16, 22 })); Assert.That(e.IdxList, Is.EqualTo(new[] { 16, 22 }));
Assert.That(e.From, Is.EqualTo(0)); Assert.That(e.From, Is.EqualTo(0));
Assert.That(e.To, Is.EqualTo(10)); Assert.That(e.To, Is.EqualTo(10));
Assert.That(e.IsSelf, Is.EqualTo(1)); Assert.That(e.IsSelf, Is.EqualTo(CardOwner.Self));
Assert.That(e.Skill, Is.EqualTo("37|36|0")); Assert.That(e.Skill, Is.EqualTo("37|36|0"));
Assert.That(e.CardId, Is.Null); Assert.That(e.CardId, Is.Null);
Assert.That(e.Clan, Is.Null); Assert.That(e.Clan, Is.Null);
@@ -479,7 +480,7 @@ public class KnownListBuilderTests
Assert.That(e.Cost, Is.EqualTo(2)); Assert.That(e.Cost, Is.EqualTo(2));
Assert.That(e.SkillKeyCardIdx, Is.EqualTo(new[] { 7 })); Assert.That(e.SkillKeyCardIdx, Is.EqualTo(new[] { 7 }));
Assert.That(e.RandomTargetIdx, Is.EqualTo(new[] { 2, 3 })); Assert.That(e.RandomTargetIdx, Is.EqualTo(new[] { 2, 3 }));
Assert.That(e.IsInvoke, Is.EqualTo(1)); Assert.That(e.IsInvoke, Is.True);
Assert.That(e.AttachTarget, Is.EqualTo("12,13")); Assert.That(e.AttachTarget, Is.EqualTo("12,13"));
} }
@@ -496,7 +497,7 @@ public class KnownListBuilderTests
Assert.That(relayed!.Count, Is.EqualTo(2)); Assert.That(relayed!.Count, Is.EqualTo(2));
Assert.That(relayed[0].Skill, Is.EqualTo("a")); Assert.That(relayed[0].Skill, Is.EqualTo("a"));
Assert.That(relayed[1].Skill, Is.EqualTo("b")); Assert.That(relayed[1].Skill, Is.EqualTo("b"));
Assert.That(relayed[1].IsSelf, Is.EqualTo(0)); Assert.That(relayed[1].IsSelf, Is.EqualTo(CardOwner.Opponent));
} }
[Test] [Test]

View File

@@ -18,11 +18,11 @@ public class NoOpBotParticipantTests
p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; }; p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
var env = new MsgEnvelope( var env = new MsgEnvelope(
NetworkBattleUri.TurnEnd, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, NetworkBattleUri.TurnEnd, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
Body: new ResultCodeOnlyBody()); Body: new ResultCodeOnlyBody());
Assert.DoesNotThrowAsync(() => p.PushAsync(env, noStock: false, CancellationToken.None)); Assert.DoesNotThrowAsync(() => p.PushAsync(env, Stock.Normal, CancellationToken.None));
Assert.That(fired, Is.EqualTo(0)); Assert.That(fired, Is.EqualTo(0));
} }

View File

@@ -147,8 +147,8 @@ public class RealParticipantHandEventTests
private static MatchContext FixtureCtx() => new( private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
} }

View File

@@ -22,8 +22,8 @@ public class RealParticipantTests
// First ordered push gets playSeq = 1; second = 2; etc. // First ordered push gets playSeq = 1; second = 2; etc.
// Inspect the participant's outbound sequencer state via its public Archive. // Inspect the participant's outbound sequencer state via its public Archive.
var env = NewEnvelope(NetworkBattleUri.Matched); var env = NewEnvelope(NetworkBattleUri.Matched);
p.PushAsync(env, noStock: false, CancellationToken.None).Wait(); p.PushAsync(env, Stock.Normal, CancellationToken.None).Wait();
p.PushAsync(env, noStock: false, CancellationToken.None).Wait(); p.PushAsync(env, Stock.Normal, CancellationToken.None).Wait();
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(2)); Assert.That(p.Outbound.Archive.Count, Is.EqualTo(2));
Assert.That(p.Outbound.Archive[1].PlaySeq, Is.EqualTo(1)); Assert.That(p.Outbound.Archive[1].PlaySeq, Is.EqualTo(1));
@@ -37,7 +37,7 @@ public class RealParticipantTests
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(), var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger<RealParticipant>.Instance); NullLogger<RealParticipant>.Instance);
p.PushAsync(NewEnvelope(NetworkBattleUri.BattleFinish), noStock: true, CancellationToken.None).Wait(); p.PushAsync(NewEnvelope(NetworkBattleUri.BattleFinish), Stock.Bypass, CancellationToken.None).Wait();
// No playSeq archive entry for no-stock pushes. // No playSeq archive entry for no-stock pushes.
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(0)); Assert.That(p.Outbound.Archive.Count, Is.EqualTo(0));
@@ -97,7 +97,7 @@ public class RealParticipantTests
var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(), var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(),
NullLogger<RealParticipant>.Instance); NullLogger<RealParticipant>.Instance);
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork)); Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AwaitingInitNetwork));
} }
[Test] [Test]
@@ -108,9 +108,9 @@ public class RealParticipantTests
NullLogger<RealParticipant>.Instance); NullLogger<RealParticipant>.Instance);
// Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode. // Setter is `internal`; SVSim.UnitTests has InternalsVisibleTo on SVSim.BattleNode.
p.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady; p.Phase = SVSim.BattleNode.Sessions.HandshakePhase.AfterReady;
Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady)); Assert.That(p.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AfterReady));
} }
[Test] [Test]
@@ -121,9 +121,9 @@ public class RealParticipantTests
var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger<RealParticipant>.Instance); var a = new RealParticipant(wsA, viewerId: 1, FixtureCtx(), NullLogger<RealParticipant>.Instance);
var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger<RealParticipant>.Instance); var b = new RealParticipant(wsB, viewerId: 2, FixtureCtx(), NullLogger<RealParticipant>.Instance);
a.Phase = SVSim.BattleNode.Sessions.BattleSessionPhase.AfterReady; a.Phase = SVSim.BattleNode.Sessions.HandshakePhase.AfterReady;
Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.BattleSessionPhase.AwaitingInitNetwork), Assert.That(b.Phase, Is.EqualTo(SVSim.BattleNode.Sessions.HandshakePhase.AwaitingInitNetwork),
"B's Phase must not change when A's Phase is set."); "B's Phase must not change when A's Phase is set.");
} }
@@ -171,13 +171,13 @@ public class RealParticipantTests
private static MatchContext FixtureCtx() => new( private static MatchContext FixtureCtx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) => private static MsgEnvelope NewEnvelope(NetworkBattleUri uri) =>
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0, new(uri, ViewerId: 1, Uuid: "u", Bid: null, RetryAttempt: 0,
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
Body: new ResultCodeOnlyBody()); Body: new ResultCodeOnlyBody());
} }

View File

@@ -72,7 +72,7 @@ public class SocketIoFrameTests
[Test] [Test]
public void Encode_AckResponse_IsTypeIdAndArrayOfArgs() public void Encode_AckResponse_IsTypeIdAndArrayOfArgs()
{ {
var frame = SocketIoFrame.AckResponse(ackId: 7, arg: 123); var frame = SocketIoFrame.AckResponse(ackId: 7, pubSeqEcho: 123);
var (text, bins) = frame.Encode(); var (text, bins) = frame.Encode();
Assert.That(text, Is.EqualTo("37[123]")); Assert.That(text, Is.EqualTo("37[123]"));
@@ -125,4 +125,17 @@ public class SocketIoFrameTests
// The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\. // The event name must be JSON-escaped: each " becomes \", and the literal \ becomes \\.
Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\"")); Assert.That(text, Does.Contain("\"weird \\\"name\\\" with \\\\ backslash\""));
} }
[Test]
public void Parse_InvalidTypeChar_Throws()
{
var ex = Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("9[\"msg\"]"));
Assert.That(ex!.Message, Does.Contain("Invalid SIO type char"));
}
[Test]
public void Parse_OverflowingAckId_Throws()
{
Assert.Throws<ArgumentException>(() => SocketIoFrame.Parse("2999999999999[\"msg\"]"));
}
} }

View File

@@ -10,11 +10,11 @@ namespace SVSim.UnitTests.Matching;
[TestFixture] [TestFixture]
public class BotRosterTests public class BotRosterTests
{ {
private static MatchContext Ctx(string userName, string classId) => new( private static MatchContext Ctx(string userName, CardClass classId) => new(
SelfDeckCardIds: Array.Empty<long>(), SelfDeckCardIds: Array.Empty<long>(),
ClassId: classId, CharaId: classId, CardMasterName: "card_master_node_10015", ClassId: classId, CharaId: classId.ToWireValue(), CardMasterName: "card_master_node_10015",
CountryCode: "JP", UserName: userName, SleeveId: "0", CountryCode: "JP", UserName: userName, SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11); EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo);
private static async Task<BotRoster> NewRosterAsync(SVSimTestFactory factory) private static async Task<BotRoster> NewRosterAsync(SVSimTestFactory factory)
{ {
@@ -30,7 +30,7 @@ public class BotRosterTests
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var roster = await NewRosterAsync(factory); var roster = await NewRosterAsync(factory);
var bot = await roster.PickAsync(Ctx("PlayerA", "1")); var bot = await roster.PickAsync(Ctx("PlayerA", CardClass.Forestcraft), "123456789012");
// Series-1 enemy_ai_id values from data_dumps/client-assets/rm_ai_setting.csv — // Series-1 enemy_ai_id values from data_dumps/client-assets/rm_ai_setting.csv —
// one per class (1=Forest, 2=Sword, 3=Rune, 4=Dragon, 5=Shadow, 6=Blood, 7=Haven, 8=Portal). // one per class (1=Forest, 2=Sword, 3=Rune, 4=Dragon, 5=Shadow, 6=Blood, 7=Haven, 8=Portal).
@@ -44,7 +44,7 @@ public class BotRosterTests
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var roster = await NewRosterAsync(factory); var roster = await NewRosterAsync(factory);
var bot = await roster.PickAsync(Ctx("PlayerA", "1")); var bot = await roster.PickAsync(Ctx("PlayerA", CardClass.Forestcraft), "123456789012");
Assert.That(bot.ClassId, Is.InRange(1, 8)); Assert.That(bot.ClassId, Is.InRange(1, 8));
Assert.That(bot.CharaId, Is.InRange(1, 8)); Assert.That(bot.CharaId, Is.InRange(1, 8));
@@ -53,16 +53,33 @@ public class BotRosterTests
} }
[Test] [Test]
public async Task PickAsync_is_deterministic_per_match_context() public async Task PickAsync_is_deterministic_per_battle_id()
{ {
using var factory = new SVSimTestFactory(); using var factory = new SVSimTestFactory();
var roster = await NewRosterAsync(factory); var roster = await NewRosterAsync(factory);
var ctx = Ctx("PlayerA", "3"); var ctx = Ctx("PlayerA", CardClass.Runecraft);
var a = await roster.PickAsync(ctx); var a = await roster.PickAsync(ctx, "999888777666");
var b = await roster.PickAsync(ctx); var b = await roster.PickAsync(ctx, "999888777666");
Assert.That(a, Is.EqualTo(b), "Same ctx → same bot, so mid-flight retries get the same opponent."); Assert.That(a, Is.EqualTo(b), "Same battleId → same bot, so mid-flight retries get the same opponent.");
}
[Test]
public async Task PickAsync_varies_across_different_battle_ids()
{
using var factory = new SVSimTestFactory();
var roster = await NewRosterAsync(factory);
var ctx = Ctx("PlayerA", CardClass.Runecraft);
var seen = new HashSet<int>();
for (var i = 0; i < 20; i++)
{
var bot = await roster.PickAsync(ctx, $"{100000000000 + i}");
seen.Add(bot.AiId);
}
Assert.That(seen.Count, Is.GreaterThan(1), "Different battle IDs should pick different bots.");
} }
[Test] [Test]
@@ -74,7 +91,7 @@ public class BotRosterTests
var globals = scope.ServiceProvider.GetRequiredService<IGlobalsRepository>(); var globals = scope.ServiceProvider.GetRequiredService<IGlobalsRepository>();
var roster = new BotRoster(globals); var roster = new BotRoster(globals);
Assert.That(async () => await roster.PickAsync(Ctx("PlayerA", "1")), Assert.That(async () => await roster.PickAsync(Ctx("PlayerA", CardClass.Forestcraft), "000000000001"),
Throws.InvalidOperationException); Throws.InvalidOperationException);
} }
} }

View File

@@ -45,10 +45,10 @@ public class InProcessPairUpRankFallbackTests
private static BattlePlayer Player(long id) => private static BattlePlayer Player(long id) =>
new(id, new MatchContext( new(id, new MatchContext(
SelfDeckCardIds: Array.Empty<long>(), ClassId: "0", CharaId: "0", SelfDeckCardIds: Array.Empty<long>(), ClassId: CardClass.None, CharaId: "0",
CardMasterName: "card_master_node_10015", CardMasterName: "card_master_node_10015",
CountryCode: "JP", UserName: $"P{id}", SleeveId: "0", CountryCode: "JP", UserName: $"P{id}", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11)); EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo));
[Test] [Test]
public async Task TK2_policy_is_PvpOnly_no_fallback_regression() public async Task TK2_policy_is_PvpOnly_no_fallback_regression()

View File

@@ -91,8 +91,8 @@ public class InProcessPairUpTests
private static MatchContext Ctx() => new( private static MatchContext Ctx() => new(
SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(), SelfDeckCardIds: Enumerable.Range(1, 30).Select(_ => 100_011_010L).ToList(),
ClassId: "1", CharaId: "1", CardMasterName: "card_master_node_10015", ClassId: CardClass.Forestcraft, CharaId: "1", CardMasterName: "card_master_node_10015",
CountryCode: "KOR", UserName: "Player", SleeveId: "3000011", CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0, EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
BattleType: 11); BattleModeId: BattleModes.TakeTwo);
} }

View File

@@ -28,10 +28,10 @@ public class MatchingResolverTests
private static BattlePlayer Player(long vid = 1) => private static BattlePlayer Player(long vid = 1) =>
new(vid, new MatchContext( new(vid, new MatchContext(
SelfDeckCardIds: Array.Empty<long>(), ClassId: "0", CharaId: "0", SelfDeckCardIds: Array.Empty<long>(), ClassId: CardClass.None, CharaId: "0",
CardMasterName: "card_master_node_10015", CardMasterName: "card_master_node_10015",
CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0", CountryCode: "JP", UserName: $"P{vid}", SleeveId: "0",
EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleType: 11)); EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0, BattleModeId: BattleModes.TakeTwo));
[Test] [Test]
public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url() public async Task When_neither_flag_set_calls_pairUp_and_parks_returns_3002_with_empty_url()

View File

@@ -57,14 +57,14 @@ public class MatchContextBuilderTests
var ctx = await builder.BuildForTwoPickAsync(vid); var ctx = await builder.BuildForTwoPickAsync(vid);
Assert.That(ctx.SelfDeckCardIds, Is.EqualTo(deck)); Assert.That(ctx.SelfDeckCardIds, Is.EqualTo(deck));
Assert.That(ctx.ClassId, Is.EqualTo("5")); Assert.That(ctx.ClassId, Is.EqualTo(CardClass.Shadowcraft));
Assert.That(ctx.CharaId, Is.EqualTo("5000001")); // LeaderSkinId set Assert.That(ctx.CharaId, Is.EqualTo("5000001")); // LeaderSkinId set
Assert.That(ctx.CountryCode, Is.EqualTo("KOR")); Assert.That(ctx.CountryCode, Is.EqualTo("KOR"));
Assert.That(ctx.UserName, Is.EqualTo("Drafter")); Assert.That(ctx.UserName, Is.EqualTo("Drafter"));
Assert.That(ctx.EmblemId, Is.EqualTo(emblemId.ToString())); Assert.That(ctx.EmblemId, Is.EqualTo(emblemId.ToString()));
Assert.That(ctx.DegreeId, Is.EqualTo(degreeId.ToString())); Assert.That(ctx.DegreeId, Is.EqualTo(degreeId.ToString()));
Assert.That(ctx.IsOfficial, Is.EqualTo(0)); Assert.That(ctx.IsOfficial, Is.EqualTo(0));
Assert.That(ctx.BattleType, Is.EqualTo(11)); Assert.That(ctx.BattleModeId, Is.EqualTo(BattleModes.TakeTwo));
// Hardcoded v1 fixtures (see spec §Deferred plumbing) // Hardcoded v1 fixtures (see spec §Deferred plumbing)
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015")); Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
Assert.That(ctx.FieldId, Is.EqualTo(43)); Assert.That(ctx.FieldId, Is.EqualTo(43));
@@ -131,12 +131,39 @@ public class MatchContextBuilderTests
var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Rotation, deckNo: 1); var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Rotation, deckNo: 1);
Assert.That(ctx.UserName, Is.EqualTo("Ranker")); Assert.That(ctx.UserName, Is.EqualTo("Ranker"));
Assert.That(ctx.BattleType, Is.EqualTo(11), "BattleType=11 matches the prod rank-battle wire value (same as TK2)."); Assert.That(ctx.BattleModeId, Is.EqualTo(BattleModes.TakeTwo), "rank-battle carries the same mode id as TK2 on the wire.");
Assert.That(ctx.ClassId, Is.Not.Null.And.Not.Empty, "ClassId from the selected deck's class."); Assert.That(ctx.ClassId, Is.Not.EqualTo(CardClass.None), "ClassId from the selected deck's class.");
Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015")); Assert.That(ctx.CardMasterName, Is.EqualTo("card_master_node_10015"));
Assert.That(ctx.FieldId, Is.EqualTo(43)); Assert.That(ctx.FieldId, Is.EqualTo(43));
} }
[Test]
public async Task BuildForRankBattle_expands_each_deck_card_by_its_count()
{
// Regression for the "matched deck only has 1 of each card" battle-node bug:
// DeckCard is count-based (one row per unique card + a Count), so
// deck.Cards.Select(c => c.Card.Id) collapsed 3 copies into a single entry.
// The MatchContext deck must carry one entry PER PHYSICAL CARD.
await using var factory = new SVSimTestFactory();
var viewerId = await factory.SeedViewerAsync(displayName: "Ranker");
await factory.SeedGlobalsAsync();
await factory.SeedDeckAsync(viewerId, Format.Unlimited, number: 1, name: "Triples");
await factory.AddCardToDeckAsync(viewerId, Format.Unlimited, 1, 10001001L, count: 3);
await factory.AddCardToDeckAsync(viewerId, Format.Unlimited, 1, 10001002L, count: 2);
await factory.AddCardToDeckAsync(viewerId, Format.Unlimited, 1, 10001003L, count: 1);
using var scope = factory.Services.CreateScope();
var builder = scope.ServiceProvider.GetRequiredService<IMatchContextBuilder>();
var ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 1);
Assert.That(ctx.SelfDeckCardIds.Count, Is.EqualTo(6),
"3 + 2 + 1 copies must produce 6 physical card entries, not 3 unique ids.");
Assert.That(ctx.SelfDeckCardIds.Count(id => id == 10001001L), Is.EqualTo(3));
Assert.That(ctx.SelfDeckCardIds.Count(id => id == 10001002L), Is.EqualTo(2));
Assert.That(ctx.SelfDeckCardIds.Count(id => id == 10001003L), Is.EqualTo(1));
}
[Test] [Test]
public async Task BuildForRankBattle_throws_when_no_deck_for_format() public async Task BuildForRankBattle_throws_when_no_deck_for_format()
{ {
@@ -171,8 +198,8 @@ public class MatchContextBuilderTests
var deck1Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 1); var deck1Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 1);
var deck5Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 5); var deck5Ctx = await builder.BuildForRankBattleAsync(viewerId, Format.Unlimited, deckNo: 5);
Assert.That(deck1Ctx.ClassId, Is.EqualTo("1"), "deckNo=1 → class 1."); Assert.That(deck1Ctx.ClassId, Is.EqualTo(CardClass.Forestcraft), "deckNo=1 → class 1.");
Assert.That(deck5Ctx.ClassId, Is.EqualTo("6"), "deckNo=5 → class 6 (the wire-bug case)."); Assert.That(deck5Ctx.ClassId, Is.EqualTo(CardClass.Bloodcraft), "deckNo=5 → class 6 (the wire-bug case).");
} }
[Test] [Test]