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>
This commit is contained in:
@@ -20,7 +20,7 @@ public sealed class BattleNodeOptions
|
||||
/// When true, <see cref="Sessions.Participants.RealParticipant"/> emits per-frame
|
||||
/// diagnostic logs at Information level: <c>[sio-in]</c> on every inbound msg/alive/hand
|
||||
/// envelope (URI, pubSeq, ackId, dispatch decision, ack-sent flag, ack arg, inbound
|
||||
/// watermark); <c>[sio-out]</c> on every outbound push (URI, pubSeq, playSeq, noStock);
|
||||
/// 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
|
||||
/// 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
|
||||
|
||||
@@ -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).</summary>
|
||||
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("selectCard")] SelectCardEntry? SelectCard);
|
||||
|
||||
|
||||
23
SVSim.BattleNode/Protocol/KeyActionType.cs
Normal file
23
SVSim.BattleNode/Protocol/KeyActionType.cs
Normal 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,
|
||||
}
|
||||
@@ -10,7 +10,7 @@ namespace SVSim.BattleNode.Sessions;
|
||||
/// <summary>
|
||||
/// v2 broker session. Holds two participants and brokers between them. Subscribes
|
||||
/// 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"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// standing up real participants.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,6 +3,7 @@ using SVSim.BattleNode.Protocol;
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <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"/>
|
||||
/// true for control frames (BattleFinish, ack) — bypasses playSeq assignment + archive.</summary>
|
||||
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, bool NoStock);
|
||||
/// Named form of the tuple <c>ComputeFrames</c> historically returned. <paramref name="Stock"/>
|
||||
/// is <see cref="Sessions.Stock.Bypass"/> for control frames (BattleFinish, ack) — bypasses
|
||||
/// playSeq assignment + archive — and <see cref="Sessions.Stock.Normal"/> for gameplay frames.</summary>
|
||||
internal readonly record struct DispatchRoute(IBattleParticipant Target, MsgEnvelope Frame, Stock Stock);
|
||||
|
||||
@@ -7,7 +7,7 @@ internal sealed class ForwardWhenBothReadyHandler : IFrameHandler
|
||||
public IReadOnlyList<DispatchRoute> 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<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ internal sealed class InitBattleHandler : IFrameHandler
|
||||
{
|
||||
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;
|
||||
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;
|
||||
|
||||
@@ -12,7 +12,7 @@ internal sealed class InitNetworkHandler : IFrameHandler
|
||||
|
||||
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;
|
||||
return routes;
|
||||
|
||||
@@ -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<DispatchRoute>();
|
||||
|
||||
@@ -22,8 +22,8 @@ internal sealed class LoadedHandler : IFrameHandler
|
||||
var r = new List<DispatchRoute>
|
||||
{
|
||||
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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, object?>()) };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, Stock.Normal) };
|
||||
}
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<DispatchRoute>(); // Pvp-not-both-ready → drop (Bot already returned above)
|
||||
}
|
||||
|
||||
@@ -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<DispatchRoute>();
|
||||
|
||||
@@ -55,7 +55,7 @@ internal static class KnownListBuilder
|
||||
/// <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 played-card op itself never carries a <c>cardId</c>.</summary>
|
||||
public static IEnumerable<(int Idx, long CardId, CardOwner IsSelf)> MineAddOps(object? orderList)
|
||||
public static IEnumerable<MinedToken> MineAddOps(object? orderList)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> 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<object?> 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 (<see cref="StripKeyActionForOpponent"/>), 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 <see cref="MineAddOps"/>.</summary>
|
||||
public static IEnumerable<(int Idx, long CardId, CardOwner IsSelf)> MineChoicePicks(object? orderList, object? keyAction)
|
||||
public static IEnumerable<MinedToken> MineChoicePicks(object? orderList, object? keyAction)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||
|
||||
@@ -127,7 +127,7 @@ internal static class KnownListBuilder
|
||||
|
||||
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> 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
|
||||
/// <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
|
||||
/// → degrade, no desync). <c>isPremium</c> (IsFoil) is cosmetic and ignored.</summary>
|
||||
public static IEnumerable<(int Idx, long CardId, CardOwner IsSelf)> MineCopyTokens(
|
||||
public static IEnumerable<MinedToken> MineCopyTokens(
|
||||
object? orderList,
|
||||
IReadOnlyDictionary<int, long> selfMap,
|
||||
IReadOnlyDictionary<int, long> otherMap)
|
||||
@@ -168,7 +168,7 @@ internal static class KnownListBuilder
|
||||
|
||||
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> 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<string, object?> 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);
|
||||
|
||||
15
SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs
Normal file
15
SVSim.BattleNode/Sessions/Dispatch/MinedToken.cs
Normal 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);
|
||||
@@ -24,9 +24,9 @@ public interface IBattleParticipant : IAsyncDisposable
|
||||
/// <summary>Session calls this to deliver a frame from the OTHER participant
|
||||
/// (or a server-synthesized broadcast). Real impl: encode + WS-send.
|
||||
/// NoOp: swallow.</summary>
|
||||
/// <param name="noStock">True for control frames (BattleFinish, JudgeResult, ack);
|
||||
/// bypasses playSeq assignment + archive.</param>
|
||||
Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct);
|
||||
/// <param name="stock"><see cref="Stock.Bypass"/> for control frames (BattleFinish, JudgeResult,
|
||||
/// ack) — bypasses playSeq assignment + archive; <see cref="Stock.Normal"/> for gameplay frames.</param>
|
||||
Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct);
|
||||
|
||||
/// <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>
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class NoOpBotParticipant : IBattleParticipant
|
||||
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 TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
17
SVSim.BattleNode/Sessions/Stock.cs
Normal file
17
SVSim.BattleNode/Sessions/Stock.cs
Normal 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,
|
||||
}
|
||||
@@ -414,7 +414,7 @@ public class CaptureConformanceTests
|
||||
.MineCopyTokens(orderList, new Dictionary<int, long>(), 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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<MsgEnvelope, CancellationToken, Task>? 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<MsgEnvelope, CancellationToken, Task>? 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;
|
||||
|
||||
@@ -14,7 +14,7 @@ public class BattleSessionStateTests
|
||||
public MatchContext Context { get; }
|
||||
public event Func<SVSim.BattleNode.Protocol.MsgEnvelope, CancellationToken, Task>? 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;
|
||||
|
||||
@@ -131,7 +131,7 @@ public class KnownListBuilderTests
|
||||
var orderList = new List<object?> { 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<object?> { 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<int, long> { [5] = 100_011_010L };
|
||||
var otherMap = new Dictionary<int, long>();
|
||||
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<int, long>();
|
||||
var otherMap = new Dictionary<int, long> { [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<object?> { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) };
|
||||
var selfMap = new Dictionary<int, long> { [5] = 700L };
|
||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary<int, long>()).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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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.
|
||||
Assert.That(p.Outbound.Archive.Count, Is.EqualTo(0));
|
||||
|
||||
Reference in New Issue
Block a user