feat(battle-node): reveal copy tokens on play via baseIdx resolution

PlayActionsHandler + EchoHandler now call RecordCopyTokensFrom (ordered
after plain/choice mining) to resolve a copy add's baseIdx against the
side's live idx->cardId map and record copyIdx->cardId. A copy played in a
later (or same) frame synthesizes a knownList instead of degrading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 10:11:34 -04:00
parent f9c7e6124b
commit b6edfbcf15
4 changed files with 202 additions and 5 deletions

View File

@@ -67,4 +67,21 @@ internal sealed class BattleSessionState
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineChoicePicks(orderList, keyAction))
RecordToken(isSelf == 1 ? from : other, idx, cardId);
}
/// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>)
/// into the correct side's map. A copy's source lives at <c>baseIdx</c> in the actor's own index
/// space, so the resolution side == the record side, both selected by the same <c>isSelf</c> routing
/// as <see cref="RecordTokensFrom"/>. Passing the LIVE per-side maps (via
/// <see cref="GetOrSeedDeckMap"/>, not snapshots) lets a copy that references a plain/choice token
/// added earlier THIS frame resolve — provided this runs AFTER
/// <see cref="RecordTokensFrom"/>/<see cref="RecordChoicePicksFrom"/> (the handler orders it last).
/// Seeding both maps up front matters because a copy-only frame (no concrete/choice add) would never
/// have hit <see cref="RecordToken"/> yet, leaving the maps unseeded.</summary>
public void RecordCopyTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
{
var selfMap = GetOrSeedDeckMap(from);
var otherMap = GetOrSeedDeckMap(other);
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap))
RecordToken(isSelf == 1 ? from : other, idx, cardId);
}
}

View File

@@ -6,8 +6,9 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
/// node never relays it (bullet-2 audit — relaying would risk an echo->echo storm). It IS mined,
/// though: an Echo's orderList carries the same add-op shape as PlayActions
/// (SendCardDataMaker.MakeEchoData -> MakeCommonSendAndEchoCardData), so it can hold a token's real
/// identity — notably the receiver's own (isSelf:1) view of a cross-side gift. We mine it into the
/// right side's idx->cardId map and still return no routes (mining != relaying).</summary>
/// identity — notably the receiver's own (isSelf:1) view of a cross-side gift. We mine it (concrete
/// tokens and baseIdx copies) into the right side's idx->cardId map and still return no routes
/// (mining != relaying).</summary>
internal sealed class EchoHandler : IFrameHandler
{
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
@@ -16,6 +17,8 @@ internal sealed class EchoHandler : IFrameHandler
{
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
// Copy tokens ride Echo too (same add-op shape); resolve baseIdx against the side's map.
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
// No RecordChoicePicksFrom here: choice picks ride keyAction.selectCard on the generating
// SEND, not the receiver's Echo (Echo carries orderList only) — the pick is already
// recorded by PlayActionsHandler. MineChoicePicks(orderList, null) would yield nothing.

View File

@@ -6,9 +6,10 @@ namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
/// <summary>PvP PlayActions translator. Synthesizes the opponent-facing knownList from the sender's
/// idx->cardId map + the orderList move op, renames targetList -> oppoTargetList, drops orderList,
/// and forwards a stripped keyAction for choice/Discover plays ({type,cardId}; selectCard dropped
/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens) or
/// keyAction.selectCard (choice picks) mined on earlier frames; an un-generated token idx still
/// degrades to {playIdx,type} (no knownList). Bot drop (no rule).</summary>
/// for a hidden open:0 pick). Token plays resolve their cardId from add ops (concrete tokens),
/// keyAction.selectCard (choice picks), or a baseIdx copy resolved against the side's map — all mined
/// on earlier (or the same) frames; an un-generated token idx still degrades to {playIdx,type}
/// (no knownList). Bot drop (no rule).</summary>
internal sealed class PlayActionsHandler : IFrameHandler
{
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
@@ -32,6 +33,11 @@ internal sealed class PlayActionsHandler : IFrameHandler
// choiceAdd carries candidates only). Record idx->chosenCardId now so the later play reveals it.
ctx.State.RecordChoicePicksFrom(ctx.From, ctx.Other, orderList, keyAction);
// Copy/clone tokens: card:{baseIdx} points at a card in the actor's own index space; resolve it
// against that side's map and record copyIdx->cardId so the later play reveals it. Ordered after
// the plain/choice mining so a same-frame copy of a just-added token resolves against the live map.
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault("targetList"));

View File

@@ -350,6 +350,177 @@ public class BattleSessionDispatchTests
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
}
[Test]
public void Pvp_PlayActions_reveals_copy_token_generated_in_an_earlier_frame()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
// Frame 1: A plays deck card idx 3; its fanfare ADDS a concrete token idx 31 (cardId 900_111_010)
// to A's hand (limbo 50 -> hand 10).
var gen = new Dictionary<string, object?>
{
["playIdx"] = 3L, ["type"] = 30L,
["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?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 31L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?> { ["cardId"] = 900_111_010L } } },
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 31L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } },
},
};
s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen));
// Frame 2: A plays deck card idx 4; its effect COPIES the token at idx 31 into a new token idx 32
// (card:{baseIdx:31}) in A's hand.
var copy = new Dictionary<string, object?>
{
["playIdx"] = 4L, ["type"] = 30L,
["orderList"] = new List<object?>
{
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 4L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 30L } },
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 32L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?> { ["baseIdx"] = 31L, ["isPremium"] = 0L } } },
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 32L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } },
},
};
s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, copy));
// Frame 3: A plays the copy token idx 32 from hand (10) to field (20).
var play = MoveOrderList(idx: 32, from: 10, to: 20);
play["playIdx"] = 32L; play["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(pb.PlayIdx, Is.EqualTo(32));
Assert.That(pb.KnownList, Is.Not.Null, "the copy's identity was resolved from baseIdx and remembered");
Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(32));
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "copy resolved to its source token's cardId");
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
}
[Test]
public void Pvp_PlayActions_copy_of_a_token_added_in_the_same_frame_resolves()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
// One frame: A's play ADDS concrete token idx 40 (cardId 900_222_020), then COPIES it to idx 41
// (card:{baseIdx:40}) — copy op AFTER the concrete add in the same orderList. The copy must
// resolve against the live map (copy mining runs after plain mining).
var frame = new Dictionary<string, object?>
{
["playIdx"] = 3L, ["type"] = 30L,
["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?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 40L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?> { ["cardId"] = 900_222_020L } } },
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 41L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?> { ["baseIdx"] = 40L, ["isPremium"] = 0L } } },
},
};
s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, frame));
// Later: A plays the copy idx 41 (hand 10 -> field 20). Reveal proves same-frame chaining.
var play = MoveOrderList(idx: 41, from: 10, to: 20);
play["playIdx"] = 41L; play["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(pb.KnownList, Is.Not.Null, "copy of a same-frame add resolved against the live map");
Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(900_222_020L));
}
[Test]
public void Pvp_PlayActions_copy_with_unknown_baseIdx_degrades_to_no_knownList()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
// A's play copies a baseIdx (99) that was never recorded → no identity to resolve.
var frame = new Dictionary<string, object?>
{
["playIdx"] = 3L, ["type"] = 30L,
["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?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 32L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?> { ["baseIdx"] = 99L, ["isPremium"] = 0L } } },
new Dictionary<string, object?> { ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 32L }, ["isSelf"] = 1L, ["from"] = 50L, ["to"] = 10L } },
},
};
s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, frame));
var play = MoveOrderList(idx: 32, from: 10, to: 20);
play["playIdx"] = 32L; play["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(pb.KnownList, Is.Null, "unknown baseIdx → no record → degrade to {playIdx,type}");
}
[Test]
public void Pvp_Echo_mines_copy_token_for_a_later_reveal()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
// Frame 1: A plays a card adding a concrete token idx 31 (cardId 900_333_030) to A's hand.
var gen = new Dictionary<string, object?>
{
["playIdx"] = 3L, ["type"] = 30L,
["orderList"] = new List<object?>
{
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 31L }, ["isSelf"] = 1L,
["card"] = new Dictionary<string, object?> { ["cardId"] = 900_333_030L } } },
},
};
s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gen));
// Frame 2: B sends an Echo describing a copy of A's idx 31 (isSelf:0 from B = the opponent A's
// index space) into a new token idx 32. Echo is mined but returns no routes.
var echo = new Dictionary<string, object?>
{
["playIdx"] = 5L, ["type"] = 31L,
["orderList"] = new List<object?>
{
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 32L }, ["isSelf"] = 0L,
["card"] = new Dictionary<string, object?> { ["baseIdx"] = 31L, ["isPremium"] = 0L } } },
},
};
var echoRoutes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.Echo, echo));
Assert.That(echoRoutes, Is.Empty, "Echo is mined, never relayed");
// Frame 3: A plays the copy token idx 32; B must see its real identity.
var play = MoveOrderList(idx: 32, from: 10, to: 20);
play["playIdx"] = 32L; play["type"] = 30L;
var routes = s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, play));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(routes[0].Target, Is.SameAs(b));
Assert.That(pb.KnownList, Is.Not.Null, "copy mined from the Echo into A's map");
Assert.That(pb.KnownList!.Single().CardId, Is.EqualTo(900_333_030L));
}
[Test]
public void Pvp_PlayActions_choice_token_records_pick_and_strips_selectCard()
{