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

@@ -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()
{