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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user