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:
@@ -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