diff --git a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs index 6b190a1..36e8c25 100644 --- a/SVSim.BattleNode/Bridge/BattleNodeOptions.cs +++ b/SVSim.BattleNode/Bridge/BattleNodeOptions.cs @@ -20,7 +20,7 @@ public sealed class BattleNodeOptions /// When true, emits per-frame /// diagnostic logs at Information level: [sio-in] on every inbound msg/alive/hand /// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound - /// watermark); [sio-out] on every outbound push (URI, pubSeq, playSeq, noStock); + /// watermark); [sio-out] on every outbound push (URI, pubSeq, playSeq, stock); /// [ws-rx-text] / [ws-rx-bin] on every WS frame received at the transport /// layer; [ws-recv-exit] / [ws-loop-exit] on read-loop termination /// (with WebSocket state + exception type when applicable). Default false — keeps diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs index d1165ad..ac3463e 100644 --- a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs +++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs @@ -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, /// provisional pending live confirmation). public sealed record KeyActionEntry( - [property: JsonPropertyName("type")] int Type, + [property: JsonPropertyName("type")] + [property: JsonConverter(typeof(JsonNumberEnumConverter))] KeyActionType Type, [property: JsonPropertyName("cardId")] long CardId, [property: JsonPropertyName("selectCard")] SelectCardEntry? SelectCard); diff --git a/SVSim.BattleNode/Protocol/KeyActionType.cs b/SVSim.BattleNode/Protocol/KeyActionType.cs new file mode 100644 index 0000000..94ad045 --- /dev/null +++ b/SVSim.BattleNode/Protocol/KeyActionType.cs @@ -0,0 +1,23 @@ +namespace SVSim.BattleNode.Protocol; + +/// +/// Wire value of type on a keyAction entry — what kind of card-generating choice the play +/// is. Mirrors the client's SendKeyActionDataManager.KeyActionType exactly (same ordinals); +/// the client reads it back via ConvertToInt(...), so it serializes as the underlying int +/// via . The node currently +/// relays only and +/// ( / KnownListBuilder.StripKeyActionForOpponent); the +/// rest are defined so the guard compares against named values instead of bare ints. +/// +public enum KeyActionType +{ + None = 0, + Choice = 1, + Accelerated = 2, + Crystallize = 3, + Fusion = 4, + HaveBeforeSkillChoice = 5, + BurialRate = 6, + ChoiceEvolution = 7, + ChoiceBrave = 8, +} diff --git a/SVSim.BattleNode/Sessions/BattleSession.cs b/SVSim.BattleNode/Sessions/BattleSession.cs index a6b5dd6..bb54819 100644 --- a/SVSim.BattleNode/Sessions/BattleSession.cs +++ b/SVSim.BattleNode/Sessions/BattleSession.cs @@ -10,7 +10,7 @@ namespace SVSim.BattleNode.Sessions; /// /// v2 broker session. Holds two participants and brokers between them. Subscribes /// to each participant's ; on each frame, -/// runs to determine the routing (target + frame + noStock +/// runs to determine the routing (target + frame + /// flag) and dispatches via . /// /// @@ -112,7 +112,7 @@ public sealed class BattleSession try { await survivor.PushAsync( - BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), noStock: true, cancellation) + BattleFrames.BuildBattleFinish(BattleResult.DisconnectWin), Stock.Bypass, cancellation) .ConfigureAwait(false); } catch (Exception ex) @@ -176,9 +176,9 @@ public sealed class BattleSession try { 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) @@ -193,7 +193,7 @@ public sealed class BattleSession /// /// 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 /// . Extracted so unit tests can drive the dispatch without /// standing up real participants. /// diff --git a/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs b/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs index d3dea72..0d5f5ba 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/DispatchRoute.cs @@ -3,6 +3,7 @@ using SVSim.BattleNode.Protocol; namespace SVSim.BattleNode.Sessions.Dispatch; /// One routing decision: deliver to . -/// Named form of the tuple ComputeFrames historically returned. -/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive. -internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock); +/// Named form of the tuple ComputeFrames historically returned. +/// is for control frames (BattleFinish, ack) — bypasses +/// playSeq assignment + archive — and for gameplay frames. +internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, Stock Stock); diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs index 8ef8f47..761132e 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/ForwardWhenBothReadyHandler.cs @@ -7,7 +7,7 @@ internal sealed class ForwardWhenBothReadyHandler : IFrameHandler public IReadOnlyList Handle(FrameDispatchContext ctx) { if (ctx.BothAfterReady()) - return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) }; + return new[] { new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal) }; return Array.Empty(); } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs index ae40040..f27cc65 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitBattleHandler.cs @@ -12,7 +12,7 @@ internal sealed class InitBattleHandler : IFrameHandler { var r = new List { - new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), true), + new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitBattle), Stock.Bypass), }; ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded; return r; @@ -26,7 +26,7 @@ internal sealed class InitBattleHandler : IFrameHandler new(ctx.From, ServerBattleFrames.BuildMatched( ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, ctx.Other.ViewerId, ctx.BattleId, BattleSeeds.Stable(ctx.State.MasterSeed), - ctx.State.GetShuffledDeck(ctx.From)), false), + ctx.State.GetShuffledDeck(ctx.From)), Stock.Normal), }; ctx.SenderPhase = BattleSessionPhase.AwaitingLoaded; return r; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitNetworkHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitNetworkHandler.cs index f3d8661..628d200 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitNetworkHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/InitNetworkHandler.cs @@ -12,7 +12,7 @@ internal sealed class InitNetworkHandler : IFrameHandler var routes = new List { - new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), true), + new(ctx.From, BattleFrames.BuildAck(NetworkBattleUri.InitNetwork), Stock.Bypass), }; ctx.SenderPhase = BattleSessionPhase.AwaitingInitBattle; return routes; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs index 2b03e9a..2ec216d 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/JudgeHandler.cs @@ -18,7 +18,7 @@ internal sealed class JudgeHandler : IFrameHandler if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) { 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(); diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs index e615fe9..2999f0a 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/LoadedHandler.cs @@ -22,8 +22,8 @@ internal sealed class LoadedHandler : IFrameHandler var r = new List { new(ctx.From, ServerBattleFrames.BuildBattleStart( - ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), false), - new(ctx.From, ServerBattleFrames.BuildDeal(), false), + ctx.From.Context, ctx.Other.Context, ctx.From.ViewerId, turnState), Stock.Normal), + new(ctx.From, ServerBattleFrames.BuildDeal(), Stock.Normal), }; ctx.SenderPhase = BattleSessionPhase.AwaitingSwap; return r; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index 346e09c..9ccfe97 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -59,6 +59,6 @@ internal sealed class PlayActionsHandler : IFrameHandler KeyAction: KnownListBuilder.StripKeyActionForOpponent(keyAction)); var frame = ctx.Env with { Body = body }; - return new[] { new DispatchRoute(ctx.Other, frame, false) }; + return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) }; } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/RetireKillHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/RetireKillHandler.cs index b056d88..cf8a72f 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/RetireKillHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/RetireKillHandler.cs @@ -9,8 +9,8 @@ internal sealed class RetireKillHandler : IFrameHandler ctx.State.SessionPhase = BattleSessionPhase.Terminal; return new[] { - new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), true), - new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), true), + new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.RetireLose), Stock.Bypass), + new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.RetireWin), Stock.Bypass), }; } } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs index 989f96e..3e06276 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/SwapHandler.cs @@ -15,7 +15,7 @@ internal sealed class SwapHandler : IFrameHandler var hand = ServerBattleFrames.ComputeHandAfterSwap(BattleFrames.ExtractIdxList(ctx.Env)); // 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.SenderPhase = BattleSessionPhase.AfterReady; @@ -32,7 +32,7 @@ internal sealed class SwapHandler : IFrameHandler && ctx.State.PostSwapHands.TryGetValue(opponent, out var oppoHand) ? ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], oppoHand, idxSeed) : ServerBattleFrames.BuildReady(ctx.State.PostSwapHands[p], idxSeed); - routes.Add(new DispatchRoute(p, ready, false)); + routes.Add(new DispatchRoute(p, ready, Stock.Normal)); } } return routes; diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs index fbd4db9..1ff09f7 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndActionsHandler.cs @@ -12,7 +12,7 @@ internal sealed class TurnEndActionsHandler : IFrameHandler if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) { var frame = ctx.Env with { Body = new RawBody(new Dictionary()) }; - return new[] { new DispatchRoute(ctx.Other, frame, false) }; + return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) }; } return Array.Empty(); } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs index 2a13d41..3981a6c 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndFinalHandler.cs @@ -8,7 +8,7 @@ internal sealed class TurnEndFinalHandler : IFrameHandler { // case 4: Bot — Judge to sender only. if (ctx.Type == BattleType.Bot && ctx.SenderPhase == BattleSessionPhase.AfterReady) - 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. if (ctx.SenderPhase == BattleSessionPhase.AfterReady) @@ -16,9 +16,9 @@ internal sealed class TurnEndFinalHandler : IFrameHandler ctx.State.SessionPhase = BattleSessionPhase.Terminal; return new[] { - new DispatchRoute(ctx.Other, ctx.Env, false), - new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), true), - new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), true), + new DispatchRoute(ctx.Other, ctx.Env, Stock.Normal), + new DispatchRoute(ctx.From, BattleFrames.BuildBattleFinish(BattleResult.LifeWin), Stock.Bypass), + new DispatchRoute(ctx.Other, BattleFrames.BuildBattleFinish(BattleResult.LifeLose), Stock.Bypass), }; } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs index 8dbc55c..c74bb83 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnEndHandler.cs @@ -9,7 +9,7 @@ internal sealed class TurnEndHandler : IFrameHandler { // 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) - 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 // (handover gate). Any non-Pvp non-Bot type that reaches AfterReady consumes the frame. @@ -21,7 +21,7 @@ internal sealed class TurnEndHandler : IFrameHandler // 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. 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(); // Pvp-not-both-ready → drop (Bot already returned above) } diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs index 89d265a..798d157 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/TurnStartHandler.cs @@ -13,7 +13,7 @@ internal sealed class TurnStartHandler : IFrameHandler if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady()) { 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(); diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs index 632c7cc..17187cb 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -55,7 +55,7 @@ internal static class KnownListBuilder /// idx-is-list guards. This is the only place a freshly-generated card's identity exists on /// the wire (bullet-3 audit F1; producing code RegisterToken/RegisterActionBase) — /// the played-card op itself never carries a cardId. - public static IEnumerable<(int Idx, long CardId, CardOwner IsSelf)> MineAddOps(object? orderList) + public static IEnumerable MineAddOps(object? orderList) { if (orderList is not IEnumerable ops) yield break; foreach (var op in ops) @@ -72,7 +72,7 @@ internal static class KnownListBuilder if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) - yield return ((int)AsLong(i), cardId, isSelf); + yield return new MinedToken((int)AsLong(i), cardId, isSelf); } } @@ -87,7 +87,7 @@ internal static class KnownListBuilder /// only gates the strip (), not the recording. An add whose /// candidates contain none of the picks is skipped (defensive — no record, no desync); Echo (no /// keyAction) yields nothing, leaving it mining-only via . - public static IEnumerable<(int Idx, long CardId, CardOwner IsSelf)> MineChoicePicks(object? orderList, object? keyAction) + public static IEnumerable MineChoicePicks(object? orderList, object? keyAction) { if (orderList is not IEnumerable ops) yield break; @@ -127,7 +127,7 @@ internal static class KnownListBuilder if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) - yield return ((int)AsLong(i), chosen.Value, isSelf); + yield return new MinedToken((int)AsLong(i), chosen.Value, isSelf); } } @@ -144,7 +144,7 @@ internal static class KnownListBuilder /// candidates (→ MineChoicePicks), a string baseIdx (private-group copy, /// RegisterCopyToken.cs:19-22), and a baseIdx absent from the chosen map (unknown source /// → degrade, no desync). isPremium (IsFoil) is cosmetic and ignored. - public static IEnumerable<(int Idx, long CardId, CardOwner IsSelf)> MineCopyTokens( + public static IEnumerable MineCopyTokens( object? orderList, IReadOnlyDictionary selfMap, IReadOnlyDictionary otherMap) @@ -168,7 +168,7 @@ internal static class KnownListBuilder if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable idxList) continue; foreach (var i in idxList) - yield return ((int)AsLong(i), cardId, isSelf); + yield return new MinedToken((int)AsLong(i), cardId, isSelf); } } @@ -186,8 +186,8 @@ internal static class KnownListBuilder { if (e is not IDictionary d) continue; d.TryGetValue("type", out var typeRaw); - var type = (int)AsLong(typeRaw); - if (type is not (1 or 5)) continue; // only Choice / HaveBeforeSkillChoice handled + var type = (KeyActionType)(int)AsLong(typeRaw); + if (type is not (KeyActionType.Choice or KeyActionType.HaveBeforeSkillChoice)) continue; d.TryGetValue("cardId", out var cardIdRaw); var cardId = AsLong(cardIdRaw); diff --git a/SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs b/SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs new file mode 100644 index 0000000..dd8772d --- /dev/null +++ b/SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs @@ -0,0 +1,15 @@ +using SVSim.BattleNode.Protocol; + +namespace SVSim.BattleNode.Sessions.Dispatch; + +/// One generated-token identity mined from a sender's orderList add op: +/// the token's in a side's index space, its resolved +/// , and — whose map it belongs to (the +/// sender's own token vs a cross-side gift living in the opponent's index space; routed by +/// ). Replaces the transpose-prone +/// (int Idx, long CardId, CardOwner IsSelf) tuple the Mine* methods returned: +/// Idx and CardId are both numeric, so (cardId, idx, …) silently compiled +/// and corrupted the reveal map. As a positional record struct it keeps the named members and +/// positional deconstruct (call sites stay foreach (var (idx, cardId, isSelf) in …)) +/// while the compiler rejects a transposed construction. +internal readonly record struct MinedToken(int Idx, long CardId, CardOwner IsSelf); diff --git a/SVSim.BattleNode/Sessions/IBattleParticipant.cs b/SVSim.BattleNode/Sessions/IBattleParticipant.cs index 1c0b539..df9791f 100644 --- a/SVSim.BattleNode/Sessions/IBattleParticipant.cs +++ b/SVSim.BattleNode/Sessions/IBattleParticipant.cs @@ -24,9 +24,9 @@ public interface IBattleParticipant : IAsyncDisposable /// Session calls this to deliver a frame from the OTHER participant /// (or a server-synthesized broadcast). Real impl: encode + WS-send. /// NoOp: swallow. - /// True for control frames (BattleFinish, JudgeResult, ack); - /// bypasses playSeq assignment + archive. - Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct); + /// for control frames (BattleFinish, JudgeResult, + /// ack) — bypasses playSeq assignment + archive; for gameplay frames. + Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct); /// 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. diff --git a/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs b/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs index b82343a..ca7cbd5 100644 --- a/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs +++ b/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs @@ -31,7 +31,7 @@ public sealed class NoOpBotParticipant : IBattleParticipant public event Func? 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 TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; diff --git a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs index e4c72ab..413f2f8 100644 --- a/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs +++ b/SVSim.BattleNode/Sessions/Participants/RealParticipant.cs @@ -211,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) { _log.LogInformation( - "[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} noStock={NoStock}", - ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, noStock); + "[sio-out] viewer={Vid} uri={Uri} pubSeq={Pseq} playSeq={Plseq} stock={Stock}", + ViewerId, stamped.Uri, stamped.PubSeq, stamped.PlaySeq, stock); } await EncodeAndSendAsync(stamped, WireConstants.SynchronizeEvent, ct); } diff --git a/SVSim.BattleNode/Sessions/Stock.cs b/SVSim.BattleNode/Sessions/Stock.cs new file mode 100644 index 0000000..c991926 --- /dev/null +++ b/SVSim.BattleNode/Sessions/Stock.cs @@ -0,0 +1,17 @@ +namespace SVSim.BattleNode.Sessions; + +/// +/// How a pushed frame interacts with the per-participant OutboundSequencer: whether it +/// gets a playSeq and is archived for ordered replay, or bypasses both. Replaces a bare +/// (and negatively-named) bool noStock threaded through +/// and — the literal true/false at call sites gave +/// no hint which sense was which, and was trivial to invert. +/// +public enum Stock +{ + /// Gameplay frame: assign the next playSeq and archive it for ordered replay. + Normal = 0, + + /// Control frame (BattleFinish, JudgeResult, ack): bypass playSeq assignment + archive. + Bypass = 1, +} diff --git a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs index 9f5ded0..1126463 100644 --- a/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/CaptureConformanceTests.cs @@ -414,7 +414,7 @@ public class CaptureConformanceTests .MineCopyTokens(orderList, new Dictionary(), otherMap) .ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (49, 123_456_789L, CardOwner.Opponent) })); + Assert.That(mined, Is.EquivalentTo(new[] { new SVSim.BattleNode.Sessions.Dispatch.MinedToken(49, 123_456_789L, CardOwner.Opponent) })); } [Test] diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchConcurrencyTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchConcurrencyTests.cs index 7333da5..8e35852 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchConcurrencyTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchConcurrencyTests.cs @@ -116,7 +116,7 @@ public class BattleSessionDispatchConcurrencyTests public Task RaiseAsync(MsgEnvelope env) => FrameEmitted?.Invoke(env, CancellationToken.None) ?? Task.CompletedTask; - public Task PushAsync(MsgEnvelope env, bool noStock, CancellationToken ct) => _detector.EnterAsync(); + 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; diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs index 1615ad1..bf34b13 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionDispatchTests.cs @@ -653,7 +653,7 @@ public class BattleSessionDispatchTests 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. 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().SelectCard, Is.Null, "the pick stays hidden for open:0"); } @@ -864,8 +864,8 @@ public class BattleSessionDispatchTests Assert.That(((BattleFinishBody)aRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireLose)); Assert.That(bRoute.Frame.Uri, Is.EqualTo(NetworkBattleUri.BattleFinish)); Assert.That(((BattleFinishBody)bRoute.Frame.Body).Result, Is.EqualTo(BattleResult.RetireWin)); - Assert.That(aRoute.NoStock, Is.True); - Assert.That(bRoute.NoStock, Is.True); + Assert.That(aRoute.Stock, Is.EqualTo(Stock.Bypass)); + Assert.That(bRoute.Stock, Is.EqualTo(Stock.Bypass)); Assert.That(s.Phase, Is.EqualTo(BattleSessionPhase.Terminal)); } @@ -1099,7 +1099,7 @@ public class BattleSessionDispatchTests public MatchContext Context { get; } public event Func? FrameEmitted; 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 TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; @@ -1117,7 +1117,7 @@ public class BattleSessionDispatchTests public BattleSessionPhase Phase { get; set; } = BattleSessionPhase.AwaitingInitNetwork; public event Func? FrameEmitted; 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 TerminateAsync(BattleFinishReason reason) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs index 36faa1f..13ff0b0 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs @@ -14,7 +14,7 @@ public class BattleSessionStateTests public MatchContext Context { get; } public event Func? FrameEmitted; 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 TerminateAsync(BattleFinishReason r) => Task.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask; diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index b5cb4ad..a40f7fe 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -131,7 +131,7 @@ public class KnownListBuilderTests var orderList = new List { AddOp(new[] { 31L, 32L }, 900111010L) }; var mined = KnownListBuilder.MineAddOps(orderList).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, CardOwner.Self), (32, 900111010L, CardOwner.Self) })); + Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Self), new MinedToken(32, 900111010L, CardOwner.Self) })); } [Test] @@ -142,7 +142,7 @@ public class KnownListBuilderTests // it; the caller routes it into the OTHER side's map. var orderList = new List { AddOp(new[] { 31L }, 900111010L, isSelf: 0) }; Assert.That(KnownListBuilder.MineAddOps(orderList), - Is.EquivalentTo(new[] { (31, 900111010L, CardOwner.Opponent) })); + Is.EquivalentTo(new[] { new MinedToken(31, 900111010L, CardOwner.Opponent) })); } [Test] @@ -205,7 +205,7 @@ public class KnownListBuilderTests AddOp(new[] { 32L }, 900811090L), }; var mined = KnownListBuilder.MineAddOps(orderList).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, CardOwner.Self), (32, 900811090L, CardOwner.Self) })); + 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 — @@ -248,7 +248,7 @@ public class KnownListBuilderTests var keyAction = KeyActionChoice(generatingCardId: 810014030L, chosen: new[] { 810041260L }, open: 0); Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), - Is.EquivalentTo(new[] { (46, 810041260L, CardOwner.Self) })); + Is.EquivalentTo(new[] { new MinedToken(46, 810041260L, CardOwner.Self) })); } [Test] @@ -260,7 +260,7 @@ public class KnownListBuilderTests var keyAction = KeyActionChoice(810014030L, new[] { 101041020L }, open: 0); Assert.That(KnownListBuilder.MineChoicePicks(orderList, keyAction), - Is.EquivalentTo(new[] { (46, 101041020L, CardOwner.Opponent) })); + Is.EquivalentTo(new[] { new MinedToken(46, 101041020L, CardOwner.Opponent) })); } [Test] @@ -303,7 +303,7 @@ public class KnownListBuilderTests Assert.That(stripped, Is.Not.Null); 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].SelectCard, Is.Null); } @@ -357,7 +357,7 @@ public class KnownListBuilderTests var selfMap = new Dictionary { [5] = 100_011_010L }; var otherMap = new Dictionary(); var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (31, 100_011_010L, CardOwner.Self) })); + Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(31, 100_011_010L, CardOwner.Self) })); } [Test] @@ -369,7 +369,7 @@ public class KnownListBuilderTests var selfMap = new Dictionary(); var otherMap = new Dictionary { [21] = 900_841_330L }; var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (49, 900_841_330L, CardOwner.Opponent) })); + Assert.That(mined, Is.EquivalentTo(new[] { new MinedToken(49, 900_841_330L, CardOwner.Opponent) })); } [Test] @@ -422,7 +422,7 @@ public class KnownListBuilderTests var orderList = new List { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) }; var selfMap = new Dictionary { [5] = 700L }; var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary()).ToList(); - Assert.That(mined, Is.EquivalentTo(new[] { (31, 700L, CardOwner.Self), (32, 700L, CardOwner.Self) })); + 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 diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs index 00090ab..4bbd80d 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs @@ -22,7 +22,7 @@ public class NoOpBotParticipantTests Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, 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)); } diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs index 859d5e9..d9ed77f 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/RealParticipantTests.cs @@ -22,8 +22,8 @@ public class RealParticipantTests // First ordered push gets playSeq = 1; second = 2; etc. // Inspect the participant's outbound sequencer state via its public Archive. var env = NewEnvelope(NetworkBattleUri.Matched); - p.PushAsync(env, noStock: false, CancellationToken.None).Wait(); - p.PushAsync(env, noStock: false, CancellationToken.None).Wait(); + p.PushAsync(env, Stock.Normal, 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[1].PlaySeq, Is.EqualTo(1)); @@ -37,7 +37,7 @@ public class RealParticipantTests var p = new RealParticipant(ws, viewerId: 1, FixtureCtx(), NullLogger.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. Assert.That(p.Outbound.Archive.Count, Is.EqualTo(0));