Merge: BattleNode deterministic-turn translator (vanilla PvP slice)
Per-URI PvP frame translator + live-validated TurnEnd<->Judge handover. Full vanilla two-client match plays end-to-end (card plays, combat, evolves, fanfares) synced through BattleFinish. 990/990 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
30
SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
Normal file
30
SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
/// <summary>Opponent-facing PlayActions frame the node synthesizes from the active player's
|
||||
/// send. <c>KnownList</c> reveals the played card's identity (null = token reveal deferred, see
|
||||
/// the deterministic-turn slice). <c>OppoTargetList</c> is the renamed <c>targetList</c>
|
||||
/// (independent of KnownList — a targeted hand play carries both). Both omitted when null via the
|
||||
/// envelope's WhenWritingNull policy.</summary>
|
||||
public sealed record PlayActionsBroadcastBody(
|
||||
[property: JsonPropertyName("playIdx")] int PlayIdx,
|
||||
[property: JsonPropertyName("type")] int Type,
|
||||
[property: JsonPropertyName("knownList")] IReadOnlyList<KnownCardEntry>? KnownList,
|
||||
[property: JsonPropertyName("oppoTargetList")] IReadOnlyList<OppoTargetEntry>? OppoTargetList) : IMsgBody;
|
||||
|
||||
/// <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
|
||||
/// port — the receiver re-derives them from cardId).</summary>
|
||||
public sealed record KnownCardEntry(
|
||||
[property: JsonPropertyName("idx")] int Idx,
|
||||
[property: JsonPropertyName("cardId")] long CardId,
|
||||
[property: JsonPropertyName("to")] int To,
|
||||
[property: JsonPropertyName("spellboost")] int Spellboost,
|
||||
[property: JsonPropertyName("attachTarget")] string AttachTarget);
|
||||
|
||||
/// <summary>Renamed <c>targetList</c> entry. <c>isSelf</c> is actor-relative and passes through
|
||||
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>
|
||||
public sealed record OppoTargetEntry(
|
||||
[property: JsonPropertyName("targetIdx")] int TargetIdx,
|
||||
[property: JsonPropertyName("isSelf")] int IsSelf);
|
||||
@@ -49,9 +49,9 @@ public sealed class BattleSession
|
||||
[NetworkBattleUri.Kill] = retireKill,
|
||||
[NetworkBattleUri.TurnStart] = new TurnStartHandler(),
|
||||
[NetworkBattleUri.Judge] = new JudgeHandler(),
|
||||
[NetworkBattleUri.PlayActions] = forwardWhenReady,
|
||||
[NetworkBattleUri.Echo] = forwardWhenReady,
|
||||
[NetworkBattleUri.TurnEndActions] = forwardWhenReady,
|
||||
[NetworkBattleUri.PlayActions] = new PlayActionsHandler(),
|
||||
[NetworkBattleUri.Echo] = new EchoHandler(),
|
||||
[NetworkBattleUri.TurnEndActions] = new TurnEndActionsHandler(),
|
||||
[NetworkBattleUri.JudgeResult] = forwardWhenReady,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,31 @@
|
||||
using SVSim.BattleNode.Sessions;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <summary>Mutable per-session state shared across frame handlers. Today: the session-level
|
||||
/// phase (only ever advanced to <see cref="BattleSessionPhase.Terminal"/>) and the mulligan
|
||||
/// barrier's post-swap hands. FUTURE (PvP equivalency, NOT this refactor): per-side idx->cardId
|
||||
/// maps + reveal-gating state land here.</summary>
|
||||
/// <summary>Mutable per-session state shared across frame handlers. The mulligan barrier's
|
||||
/// post-swap hands, plus (PvP-equivalency, vanilla slice) the per-side idx->cardId map used to
|
||||
/// synthesize the opponent-facing <c>knownList</c>. FUTURE: a token map (cardIds mined from
|
||||
/// orderList <c>add</c> ops, idx>30) + a reveal-gate set land alongside <see cref="IdxToCardId"/>.</summary>
|
||||
internal sealed class BattleSessionState
|
||||
{
|
||||
public BattleSessionPhase SessionPhase { get; set; } = BattleSessionPhase.AwaitingInitNetwork;
|
||||
public Dictionary<IBattleParticipant, long[]> PostSwapHands { get; } = new();
|
||||
|
||||
/// <summary>Per-side idx->cardId, seeded lazily from <see cref="MatchContext.SelfDeckCardIds"/>.
|
||||
/// Deck cards only (idx 1..deckCount); tokens (idx>deckCount) are deferred.</summary>
|
||||
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
|
||||
/// use. <c>BuildPlayerDeck</c> assigns deck idx = position+1, so entry (i+1) -> cardIds[i].</summary>
|
||||
public IReadOnlyDictionary<int, long> GetOrSeedDeckMap(IBattleParticipant side)
|
||||
{
|
||||
if (!IdxToCardId.TryGetValue(side, out var map))
|
||||
{
|
||||
map = new Dictionary<int, long>();
|
||||
var deck = side.Context.SelfDeckCardIds;
|
||||
for (var i = 0; i < deck.Count; i++) map[i + 1] = deck[i];
|
||||
IdxToCardId[side] = map;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>Echo is the receiver's per-frame ack; the client has no inbound Echo handler, so the
|
||||
/// node consumes it (bullet-2 audit). Relaying would risk an echo->echo storm.</summary>
|
||||
internal sealed class EchoHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) => Array.Empty<DispatchRoute>();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
@@ -6,9 +7,23 @@ internal sealed class JudgeHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// Only a scripted-bot Judge is forwarded. A real player's Judge has no routing rule (drops).
|
||||
// Scripted-bot Judge (test stub): forward verbatim (carries the {spin} shape already).
|
||||
if (ctx.IsScriptedBot(ctx.From))
|
||||
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
|
||||
|
||||
// PvP: Judge is the handover gate. The player who sends Judge is the one TAKING OVER the
|
||||
// turn (the client rule is: receive opponent TurnEnd -> SendJudge). Receiving Judge{spin}
|
||||
// fires ControlTurnStartPlayer ("start MY turn"), so the {spin} must REFLECT BACK to the
|
||||
// sender — NOT go to the opponent (that would make the player who just ended their turn
|
||||
// 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}.
|
||||
// battleCode is dropped; spin=0 for the deterministic-turn slice.
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var frame = ctx.Env with { Body = new JudgeBody(Spin: 0) };
|
||||
return new[] { new DispatchRoute(ctx.From, frame, false) };
|
||||
}
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>PvP PlayActions translator (vanilla deck-card slice). Synthesizes the opponent-facing
|
||||
/// knownList from the sender's idx->cardId map + the orderList move op, renames targetList ->
|
||||
/// oppoTargetList, drops orderList, consumes keyAction. Token plays (idx>deck) degrade silently to
|
||||
/// {playIdx,type} (no knownList). Scripted/Bot drop (no rule).</summary>
|
||||
internal sealed class PlayActionsHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.Type != BattleType.Pvp || !ctx.BothAfterReady())
|
||||
return Array.Empty<DispatchRoute>();
|
||||
|
||||
var entries = (ctx.Env.Body as RawBody)?.Entries ?? new Dictionary<string, object?>();
|
||||
var playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("playIdx"));
|
||||
var type = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault("type"));
|
||||
|
||||
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
||||
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, entries.GetValueOrDefault("orderList"));
|
||||
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));
|
||||
|
||||
var body = new PlayActionsBroadcastBody(
|
||||
PlayIdx: playIdx,
|
||||
Type: type,
|
||||
KnownList: played is null ? null : new[] { played },
|
||||
OppoTargetList: oppoTargets);
|
||||
|
||||
var frame = ctx.Env with { Body = body };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>PvP TurnEndActions: the sender's orderList is dropped; the opponent receives an
|
||||
/// empty body (it only flips _sendEcho + runs the opponent's end-of-turn triggers via the
|
||||
/// opponent's own engine). Scripted/Bot drop.</summary>
|
||||
internal sealed class TurnEndActionsHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
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 Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
@@ -16,15 +17,11 @@ internal sealed class TurnEndHandler : IFrameHandler
|
||||
{
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var te = BattleFrames.BuildTurnEndBroadcast();
|
||||
var jg = BattleFrames.BuildJudgeBroadcast();
|
||||
return new[]
|
||||
{
|
||||
new DispatchRoute(ctx.From, te, false),
|
||||
new DispatchRoute(ctx.Other, te, false),
|
||||
new DispatchRoute(ctx.From, jg, false),
|
||||
new DispatchRoute(ctx.Other, jg, false),
|
||||
};
|
||||
// Opponent sees {turnState}; receiving TurnEnd drives ITS SendJudge (handover gate):
|
||||
// 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: 0) };
|
||||
return new[] { new DispatchRoute(ctx.Other, te, false) };
|
||||
}
|
||||
if (ctx.Type == BattleType.Scripted)
|
||||
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
@@ -6,10 +7,18 @@ internal sealed class TurnStartHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
// Forward the opponent's turn-open to the other side. Union of the two legacy arms:
|
||||
// BothAfterReady (PvP / scripted real player) OR a scripted-bot emission (test stub path).
|
||||
if (ctx.BothAfterReady() || ctx.IsScriptedBot(ctx.From))
|
||||
// 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.
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var frame = ctx.Env with { Body = new OpponentTurnStartBody(Spin: 0) };
|
||||
return new[] { new DispatchRoute(ctx.Other, frame, false) };
|
||||
}
|
||||
|
||||
// Scripted-bot emission (test stub path): the bot already emits the {spin} shape — forward.
|
||||
if (ctx.IsScriptedBot(ctx.From))
|
||||
return new[] { new DispatchRoute(ctx.Other, ctx.Env, false) };
|
||||
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
|
||||
74
SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
Normal file
74
SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using SVSim.BattleNode.Protocol.Bodies;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
/// <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
|
||||
/// as <c>Dictionary<string,object?></c> / <c>List<object?></c> with numeric leaves boxed
|
||||
/// as long/int/double (see MsgEnvelope.FromJson).</summary>
|
||||
internal static class KnownListBuilder
|
||||
{
|
||||
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
|
||||
/// (token idx not in the deck map, or no matching move op). spellboost/attachTarget default to
|
||||
/// 0/"" for the vanilla slice; cost/clan/tribe are deferred (receiver re-derives from cardId).</summary>
|
||||
public static KnownCardEntry? BuildPlayedCard(
|
||||
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList)
|
||||
{
|
||||
if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
|
||||
var to = ExtractMoveTo(orderList, playIdx);
|
||||
if (to is null) return null;
|
||||
return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: 0, AttachTarget: "");
|
||||
}
|
||||
|
||||
/// <summary>The <c>to</c> place-state of the FIRST <c>move</c> op whose <c>idx</c> list contains
|
||||
/// <paramref name="playIdx"/> (the played card's own move; later add/alter ops are the deferred
|
||||
/// token slice), or null if absent. NOTE: the sender-side <c>to</c> is passed through verbatim —
|
||||
/// for the vanilla slice we assume send-side and recv-side place-state codes match, pending
|
||||
/// recv-capture confirmation.</summary>
|
||||
public static int? ExtractMoveTo(object? orderList, int playIdx)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> ops) return null;
|
||||
foreach (var op in ops)
|
||||
{
|
||||
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 (move.TryGetValue("idx", out var idxRaw) && idxRaw is IEnumerable<object?> idxList)
|
||||
{
|
||||
foreach (var i in idxList)
|
||||
if (AsLong(i) == playIdx && move.TryGetValue("to", out var toRaw))
|
||||
return (int)AsLong(toRaw);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Rename <c>targetList</c> -> <c>oppoTargetList</c>; <c>isSelf</c> is actor-relative
|
||||
/// and passes through unchanged (F2). Null for a missing/empty list.</summary>
|
||||
public static IReadOnlyList<OppoTargetEntry>? RenameTargets(object? targetList)
|
||||
{
|
||||
if (targetList is not IEnumerable<object?> entries) return null;
|
||||
var result = new List<OppoTargetEntry>();
|
||||
foreach (var e in entries)
|
||||
{
|
||||
if (e is not IDictionary<string, object?> d) continue;
|
||||
d.TryGetValue("targetIdx", out var targetIdxRaw);
|
||||
d.TryGetValue("isSelf", out var isSelfRaw);
|
||||
result.Add(new OppoTargetEntry(
|
||||
TargetIdx: (int)AsLong(targetIdxRaw),
|
||||
IsSelf: (int)AsLong(isSelfRaw)));
|
||||
}
|
||||
return result.Count == 0 ? null : result;
|
||||
}
|
||||
|
||||
/// <summary>Coerce a boxed RawBody numeric leaf (long/int/double/decimal/string) to long; 0 for
|
||||
/// null/unparseable.</summary>
|
||||
public static long AsLong(object? value) => value switch
|
||||
{
|
||||
long l => l,
|
||||
int i => i,
|
||||
double d => (long)d,
|
||||
decimal m => (long)m,
|
||||
string s when long.TryParse(s, out var p) => p,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
@@ -220,21 +220,28 @@ public class BattleNodeFlowTests
|
||||
|
||||
await DrivePvpHandshakeAsync(clientA, vidA, clientB, vidB, key, ct);
|
||||
|
||||
// Both are now AfterReady. A sends TurnEnd; both should receive TurnEnd + Judge.
|
||||
// Both are now AfterReady. Deterministic-turn handover, mirroring the real two-client
|
||||
// capture (2026-06-03 battle_test). A ends its turn; the OPPONENT (B) receives the
|
||||
// translated {turnState:0} TurnEnd. A receives nothing — it already ran the turn locally.
|
||||
await clientA.SendMsgAsync(MakeEnvelopeWith(vidA, NetworkBattleUri.TurnEnd, pubSeq: 5), key, ct);
|
||||
var bTurnEnd = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(bTurnEnd.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
|
||||
var aFirst = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
var aSecond = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
var bFirst = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
var bSecond = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
// The client rule is: receive opponent TurnEnd -> SendJudge. So B (the taker-over) sends
|
||||
// Judge. The {spin:0} reflects BACK to B (its own ControlTurnStartPlayer gate), NOT to A —
|
||||
// routing it to A would restart A's turn and stall the loop (the live-run bug this fixes).
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.Judge, pubSeq: 5), key, ct);
|
||||
var bJudge = await clientB.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(bJudge.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
|
||||
Assert.That(new[] { aFirst.Uri, aSecond.Uri },
|
||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
Assert.That(new[] { bFirst.Uri, bSecond.Uri },
|
||||
Is.EquivalentTo(new[] { NetworkBattleUri.TurnEnd, NetworkBattleUri.Judge }));
|
||||
// B opens its turn: TurnStart relays to the opponent A as {spin:0} ("opponent's turn").
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.TurnStart, pubSeq: 6), key, ct);
|
||||
var aTurnStart = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(aTurnStart.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
|
||||
// PlayActions forwarding: B sends, A receives.
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 6), key, ct);
|
||||
// PlayActions translation: B plays a card; A receives the opponent-facing PlayActions
|
||||
// frame (Uri preserved, body synthesized by PlayActionsHandler).
|
||||
await clientB.SendMsgAsync(MakeEnvelopeWith(vidB, NetworkBattleUri.PlayActions, pubSeq: 7), key, ct);
|
||||
var aForwarded = await clientA.ReceiveSynchronizeAsync(ct);
|
||||
Assert.That(aForwarded.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
||||
}
|
||||
|
||||
@@ -221,6 +221,48 @@ public class CaptureConformanceTests
|
||||
var seq = 0;
|
||||
return NodeCrypto.GenerateKey(() => (seq++ * 13) % 16);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SynthesizedKnownList_matches_prod_recv_PlayActions_entry_shape()
|
||||
{
|
||||
// Prod recv PlayActions knownList entry (battle-traffic_tk2_regular.ndjson:27).
|
||||
const string prodEntry = """
|
||||
{ "idx": 17, "cardId": 128821011, "to": 20, "cost": 2, "clan": 8, "tribe": "7,16", "spellboost": 0, "attachTarget": "" }
|
||||
""";
|
||||
|
||||
// Build the same entry through our synthesizer.
|
||||
var deckMap = new Dictionary<int, long> { [17] = 128821011L };
|
||||
var orderList = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { 17L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L,
|
||||
}
|
||||
}
|
||||
};
|
||||
var entry = SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.BuildPlayedCard(deckMap, 17, orderList);
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
|
||||
var body = new SVSim.BattleNode.Protocol.Bodies.PlayActionsBroadcastBody(
|
||||
PlayIdx: 17, Type: 30, KnownList: new[] { entry! }, OppoTargetList: null);
|
||||
var env = new MsgEnvelope(NetworkBattleUri.PlayActions, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: body);
|
||||
|
||||
using var ourDoc = JsonDocument.Parse(MsgEnvelope.ToJson(env));
|
||||
var ourEntry = ourDoc.RootElement.GetProperty("knownList")[0];
|
||||
using var prodDoc = JsonDocument.Parse(prodEntry);
|
||||
|
||||
// We are responsible for idx/cardId/to (+ spellboost/attachTarget). cost/clan/tribe are deferred.
|
||||
foreach (var key in new[] { "idx", "cardId", "to" })
|
||||
{
|
||||
Assert.That(ourEntry.TryGetProperty(key, out var ours), Is.True, $"knownList entry missing '{key}'");
|
||||
var prodVal = prodDoc.RootElement.GetProperty(key);
|
||||
Assert.That(ours.ValueKind, Is.EqualTo(prodVal.ValueKind), $"'{key}' type category mismatch");
|
||||
}
|
||||
Assert.That(ourEntry.GetProperty("cardId").GetInt64(), Is.EqualTo(128821011L));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -384,7 +384,7 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnStart_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_TurnStart_from_A_emits_spin0_to_B()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -395,24 +395,111 @@ public class BattleSessionDispatchTests
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnStart));
|
||||
var body = (SVSim.BattleNode.Protocol.Bodies.OpponentTurnStartBody)routes[0].Frame.Body;
|
||||
Assert.That(body.Spin, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_Judge_from_A_reflects_spin0_back_to_sender()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Judge));
|
||||
|
||||
// Judge reflects BACK to its sender (the turn taker-over), not to the opponent: receiving
|
||||
// Judge{spin} fires the sender's ControlTurnStartPlayer. Routing to the opponent would
|
||||
// restart the just-ended player's turn (2026-06-03 two-client capture).
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(a));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.Judge));
|
||||
var body = (SVSim.BattleNode.Protocol.Bodies.JudgeBody)routes[0].Frame.Body;
|
||||
Assert.That(body.Spin, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_synthesizes_knownList_from_sender_deck()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var body = MoveOrderList(idx: 3, from: 10, to: 20);
|
||||
body["playIdx"] = 3L;
|
||||
body["type"] = 30L;
|
||||
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.PlayActions));
|
||||
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||
Assert.That(pb.PlayIdx, Is.EqualTo(3));
|
||||
Assert.That(pb.Type, Is.EqualTo(30));
|
||||
Assert.That(pb.KnownList!.Count, Is.EqualTo(1));
|
||||
Assert.That(pb.KnownList[0].Idx, Is.EqualTo(3));
|
||||
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(100_011_010L)); // PlayerACtx deck cardId
|
||||
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
|
||||
Assert.That(pb.OppoTargetList, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_Echo_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_PlayActions_renames_targetList_to_oppoTargetList()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var body = MoveOrderList(idx: 3, from: 10, to: 20);
|
||||
body["playIdx"] = 3L;
|
||||
body["type"] = 31L;
|
||||
body["targetList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
|
||||
};
|
||||
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||
|
||||
Assert.That(pb.OppoTargetList!.Count, Is.EqualTo(1));
|
||||
Assert.That(pb.OppoTargetList[0].TargetIdx, Is.EqualTo(8));
|
||||
Assert.That(pb.OppoTargetList[0].IsSelf, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_token_idx_degrades_to_no_knownList()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var body = MoveOrderList(idx: 31, from: 10, to: 20); // idx 31 > 30-card deck → token
|
||||
body["playIdx"] = 31L;
|
||||
body["type"] = 30L;
|
||||
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(pb.PlayIdx, Is.EqualTo(31));
|
||||
Assert.That(pb.KnownList, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
// B not AfterReady → not BothAfterReady.
|
||||
var body = MoveOrderList(3, 10, 20);
|
||||
body["playIdx"] = 3L; body["type"] = 30L;
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, body));
|
||||
Assert.That(routes, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_Echo_from_A_in_BothAfterReady_is_consumed_not_relayed()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -420,21 +507,23 @@ public class BattleSessionDispatchTests
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.Echo));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnEndActions_from_A_in_BothAfterReady_forwards_to_B()
|
||||
public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
DriveToAfterReady(s, b);
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEndActions));
|
||||
var body = MoveOrderList(3, 20, 30); // a non-empty orderList that must be dropped
|
||||
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.TurnEndActions, body));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEndActions));
|
||||
Assert.That(((RawBody)routes[0].Frame.Body).Entries, Is.Empty, "orderList is dropped; body is empty.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -451,20 +540,7 @@ public class BattleSessionDispatchTests
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
// B is still AwaitingInitNetwork — BothAfterReady is false.
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.PlayActions));
|
||||
|
||||
Assert.That(routes, Is.Empty,
|
||||
"PvP gameplay forwarding must wait until BOTH sides reach AfterReady.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Pvp_TurnEnd_from_A_in_BothAfterReady_broadcasts_TurnEnd_plus_Judge_to_both()
|
||||
public void Pvp_TurnEnd_from_A_emits_turnState_to_B_only()
|
||||
{
|
||||
var (s, a, b) = NewPvpSession();
|
||||
DriveToAfterReady(s, a);
|
||||
@@ -472,14 +548,11 @@ public class BattleSessionDispatchTests
|
||||
|
||||
var routes = s.ComputeFrames(a, NewEnvelope(NetworkBattleUri.TurnEnd));
|
||||
|
||||
Assert.That(routes.Count, Is.EqualTo(4));
|
||||
Assert.That(routes.Select(r => (r.Target, r.Frame.Uri)), Is.EquivalentTo(new[]
|
||||
{
|
||||
((IBattleParticipant)a, NetworkBattleUri.TurnEnd),
|
||||
((IBattleParticipant)b, NetworkBattleUri.TurnEnd),
|
||||
((IBattleParticipant)a, NetworkBattleUri.Judge),
|
||||
((IBattleParticipant)b, NetworkBattleUri.Judge),
|
||||
}));
|
||||
Assert.That(routes.Count, Is.EqualTo(1));
|
||||
Assert.That(routes[0].Target, Is.SameAs(b));
|
||||
Assert.That(routes[0].Frame.Uri, Is.EqualTo(NetworkBattleUri.TurnEnd));
|
||||
var body = (SVSim.BattleNode.Protocol.Bodies.TurnEndBody)routes[0].Frame.Body;
|
||||
Assert.That(body.TurnState, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -776,6 +849,25 @@ public class BattleSessionDispatchTests
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
|
||||
Body: new RawBody(new Dictionary<string, object?>()));
|
||||
|
||||
private static MsgEnvelope EnvWith(NetworkBattleUri uri, Dictionary<string, object?> body) =>
|
||||
new(uri, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
|
||||
Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null, Body: new RawBody(body));
|
||||
|
||||
private static Dictionary<string, object?> MoveOrderList(int idx, int from, int to) => new()
|
||||
{
|
||||
["orderList"] = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { (long)idx },
|
||||
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// <summary>Data-only IBattleParticipant stub for dispatch tests. PushAsync/RunAsync
|
||||
/// are no-ops; FrameEmitted exists but is never invoked by the test.</summary>
|
||||
private sealed class FakeParticipant : IBattleParticipant
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Bridge;
|
||||
using SVSim.BattleNode.Sessions;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class BattleSessionStateTests
|
||||
{
|
||||
private sealed class StubParticipant : IBattleParticipant
|
||||
{
|
||||
public long ViewerId { get; }
|
||||
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 RunAsync(CancellationToken c) => Task.CompletedTask;
|
||||
public Task TerminateAsync(BattleFinishReason r) => Task.CompletedTask;
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
private void Touch() => FrameEmitted?.Invoke(null!, default);
|
||||
}
|
||||
|
||||
private static MatchContext Ctx(params long[] deck) => new(
|
||||
SelfDeckCardIds: deck, ClassId: "1", CharaId: "1", CardMasterName: "cm",
|
||||
CountryCode: "KOR", UserName: "P", SleeveId: "0", EmblemId: "0", DegreeId: "0",
|
||||
FieldId: 0, IsOfficial: 0, BattleType: 11);
|
||||
|
||||
[Test]
|
||||
public void GetOrSeedDeckMap_maps_idx_1based_to_deck_cardIds()
|
||||
{
|
||||
var state = new BattleSessionState();
|
||||
var p = new StubParticipant(1, Ctx(900L, 901L, 902L));
|
||||
|
||||
var map = state.GetOrSeedDeckMap(p);
|
||||
|
||||
Assert.That(map[1], Is.EqualTo(900L));
|
||||
Assert.That(map[2], Is.EqualTo(901L));
|
||||
Assert.That(map[3], Is.EqualTo(902L));
|
||||
Assert.That(map.ContainsKey(4), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GetOrSeedDeckMap_is_idempotent_same_instance()
|
||||
{
|
||||
var state = new BattleSessionState();
|
||||
var p = new StubParticipant(1, Ctx(900L));
|
||||
Assert.That(state.GetOrSeedDeckMap(p), Is.SameAs(state.GetOrSeedDeckMap(p)));
|
||||
}
|
||||
}
|
||||
114
SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
Normal file
114
SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Sessions.Dispatch;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Sessions;
|
||||
|
||||
[TestFixture]
|
||||
public class KnownListBuilderTests
|
||||
{
|
||||
// orderList as it arrives in a RawBody: a list of single-key op dicts.
|
||||
private static List<object?> OrderListMove(int idx, int from, int to) => new()
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { (long)idx },
|
||||
["isSelf"] = 1L, ["from"] = (long)from, ["to"] = (long)to,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void ExtractMoveTo_returns_to_for_matching_idx()
|
||||
{
|
||||
var to = KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 3);
|
||||
Assert.That(to, Is.EqualTo(20));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractMoveTo_returns_null_when_no_move_op_matches()
|
||||
{
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(OrderListMove(3, 10, 20), playIdx: 99), Is.Null);
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(null, playIdx: 3), Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ExtractMoveTo_returns_first_matching_move_op()
|
||||
{
|
||||
// A real PlayActions can carry several move ops; the played card's move comes first,
|
||||
// later ops (token add/alter) target other idxs. Confirm first-match-wins, not last.
|
||||
var orderList = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L,
|
||||
}
|
||||
},
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["move"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = new List<object?> { 31L, 32L }, ["isSelf"] = 1L, ["from"] = 0L, ["to"] = 40L,
|
||||
}
|
||||
},
|
||||
};
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 3), Is.EqualTo(30));
|
||||
Assert.That(KnownListBuilder.ExtractMoveTo(orderList, playIdx: 31), Is.EqualTo(40));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildPlayedCard_returns_null_for_deck_card_with_no_matching_move_op()
|
||||
{
|
||||
// idx is in the deck, but the orderList has no move op for it → can't synthesize.
|
||||
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(7, 10, 20));
|
||||
Assert.That(entry, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildPlayedCard_synthesizes_entry_for_deck_card()
|
||||
{
|
||||
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20));
|
||||
|
||||
Assert.That(entry, Is.Not.Null);
|
||||
Assert.That(entry!.Idx, Is.EqualTo(3));
|
||||
Assert.That(entry.CardId, Is.EqualTo(128821011L));
|
||||
Assert.That(entry.To, Is.EqualTo(20));
|
||||
Assert.That(entry.Spellboost, Is.EqualTo(0));
|
||||
Assert.That(entry.AttachTarget, Is.EqualTo(""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BuildPlayedCard_returns_null_for_token_idx_not_in_deck()
|
||||
{
|
||||
var deckMap = new Dictionary<int, long> { [3] = 128821011L };
|
||||
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 31, orderList: OrderListMove(31, 10, 20));
|
||||
Assert.That(entry, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RenameTargets_passes_isSelf_through_verbatim()
|
||||
{
|
||||
var targetList = new List<object?>
|
||||
{
|
||||
new Dictionary<string, object?> { ["targetIdx"] = 8L, ["isSelf"] = 0L },
|
||||
};
|
||||
var renamed = KnownListBuilder.RenameTargets(targetList);
|
||||
|
||||
Assert.That(renamed, Is.Not.Null);
|
||||
Assert.That(renamed!.Count, Is.EqualTo(1));
|
||||
Assert.That(renamed[0].TargetIdx, Is.EqualTo(8));
|
||||
Assert.That(renamed[0].IsSelf, Is.EqualTo(0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RenameTargets_returns_null_for_missing_or_empty()
|
||||
{
|
||||
Assert.That(KnownListBuilder.RenameTargets(null), Is.Null);
|
||||
Assert.That(KnownListBuilder.RenameTargets(new List<object?>()), Is.Null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user