feat(battle-node): cross-side gift + Echo-frame token mining

Close the two generated-token gaps that desynced PvP live test #3 (the
Forestcraft Fairy), both sourced from the 2026-06-03 decomp-validation table.

- MineAddOps now returns (idx, cardId, isSelf) and no longer drops isSelf:0.
  isSelf is the sender's perspective tag on CardObj.IsPlayer (RegisterToken.cs:22)
  and a card has one CardObj.Index, so an isSelf:0 add is the opponent's card.
- New shared BattleSessionState.RecordTokensFrom routes isSelf:1 -> sender,
  isSelf:0 -> opponent (the gift lives in the recipient's map, consulted when
  they play it). PlayActionsHandler delegates to it.
- EchoHandler now mines via the same helper but still returns no routes. An
  Echo's orderList carries the same add-op shape as a send (MakeEchoData ->
  MakeCommonSendAndEchoCardData), so MineAddOps applies verbatim; mining != relaying.

Choice/copy/private-group adds stay skipped (no concrete cardId). Full solution
963/963 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-04 07:59:46 -04:00
parent 155ccf0a48
commit 62251482e4
7 changed files with 133 additions and 25 deletions

View File

@@ -322,7 +322,7 @@ public class CaptureConformanceTests
["card"] = new Dictionary<string, object?> { ["cardId"] = 900811111L } } },
};
var map = new Dictionary<int, long>();
foreach (var (idx, cardId) in SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.MineAddOps(generatingOrderList))
foreach (var (idx, cardId, _) in SVSim.BattleNode.Sessions.Dispatch.KnownListBuilder.MineAddOps(generatingOrderList))
map[idx] = cardId;
var playOrderList = new List<object?>

View File

@@ -311,6 +311,45 @@ public class BattleSessionDispatchTests
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
}
[Test]
public void Pvp_PlayActions_cross_side_gift_is_revealed_when_the_opponent_plays_it()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
// A plays a card whose effect GIFTS B a token at idx 31 (isSelf:0 — from A's perspective the
// card lives in the OPPONENT's index space; RegisterToken.cs:22 sets isSelf = CardObj.IsPlayer).
// The node must record it into B's map, not A's.
var gift = 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"] = 0L,
["card"] = new Dictionary<string, object?> { ["cardId"] = 900111010L } } },
},
};
s.ComputeFrames(a, EnvWith(NetworkBattleUri.PlayActions, gift));
// Later, B plays the gifted token idx 31 (hand 10 -> field 20). A must see its real identity.
var play = MoveOrderList(idx: 31, from: 10, to: 20);
play["playIdx"] = 31L;
play["type"] = 30L;
var routes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.PlayActions, play));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(routes[0].Target, Is.SameAs(a));
Assert.That(pb.KnownList, Is.Not.Null, "the gifted token's identity was recorded into B's map");
Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(31));
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "mined cross-side gift cardId");
Assert.That(pb.KnownList[0].To, Is.EqualTo(20));
}
[Test]
public void Pvp_PlayActions_when_B_still_AwaitingSwap_drops()
{
@@ -335,6 +374,41 @@ public class BattleSessionDispatchTests
Assert.That(routes, Is.Empty, "Echo has no inbound handler on the client; relaying risks an echo storm.");
}
[Test]
public void Pvp_Echo_mines_token_identity_for_a_later_reveal()
{
var (s, a, b) = NewPvpSession();
DriveToAfterReady(s, a);
DriveToAfterReady(s, b);
// B's Echo carries its own (isSelf:1) view of a token it received at idx 31. An Echo's
// orderList carries the SAME add-op shape as PlayActions (SendCardDataMaker.MakeEchoData ->
// MakeCommonSendAndEchoCardData), so the node MINES it for the identity — but still never
// relays the Echo (no inbound client handler). Mining != relaying.
var echo = new Dictionary<string, object?>
{
["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"] = 900111010L } } },
},
};
var echoRoutes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.Echo, echo));
Assert.That(echoRoutes, Is.Empty, "Echo is mined, not relayed.");
// B plays the token idx 31 (hand 10 -> field 20); A must now see its real identity.
var play = MoveOrderList(idx: 31, from: 10, to: 20);
play["playIdx"] = 31L;
play["type"] = 30L;
var routes = s.ComputeFrames(b, EnvWith(NetworkBattleUri.PlayActions, play));
var pb = (PlayActionsBroadcastBody)routes[0].Frame.Body;
Assert.That(pb.KnownList, Is.Not.Null, "Echo-mined token identity surfaces on play");
Assert.That(pb.KnownList!.Single().Idx, Is.EqualTo(31));
Assert.That(pb.KnownList[0].CardId, Is.EqualTo(900_111_010L), "mined-from-Echo token cardId");
}
[Test]
public void Pvp_TurnEndActions_from_A_emits_empty_body_to_B()
{

View File

@@ -129,15 +129,18 @@ public class KnownListBuilderTests
var orderList = new List<object?> { AddOp(new[] { 31L, 32L }, 900111010L) };
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L), (32, 900111010L) }));
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900111010L, 1) }));
}
[Test]
public void MineAddOps_skips_add_ops_for_the_opponent_isSelf_0()
public void MineAddOps_yields_cross_side_gifts_with_isSelf_0()
{
// A card given to the opponent (isSelf:0) belongs in the other side's map — deferred.
// A card gifted to the opponent (isSelf:0) is the opponent's card at this idx (isSelf is the
// sender's perspective tag on CardObj.IsPlayer — RegisterToken.cs:22). The extractor surfaces
// it; the caller routes it into the OTHER side's map.
var orderList = new List<object?> { AddOp(new[] { 31L }, 900111010L, isSelf: 0) };
Assert.That(KnownListBuilder.MineAddOps(orderList), Is.Empty);
Assert.That(KnownListBuilder.MineAddOps(orderList),
Is.EquivalentTo(new[] { (31, 900111010L, 0) }));
}
[Test]
@@ -200,6 +203,6 @@ public class KnownListBuilderTests
AddOp(new[] { 32L }, 900811090L),
};
var mined = KnownListBuilder.MineAddOps(orderList).ToList();
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L), (32, 900811090L) }));
Assert.That(mined, Is.EquivalentTo(new[] { (31, 900111010L, 1), (32, 900811090L, 1) }));
}
}