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:
@@ -41,4 +41,19 @@ internal sealed class BattleSessionState
|
||||
GetOrSeedDeckMap(side); // ensure the per-side map exists (deck-seeded)
|
||||
IdxToCardId[side][idx] = cardId; // overwrite-on-conflict: latest identity wins
|
||||
}
|
||||
|
||||
/// <summary>Mine generated-token identities from a sender's <c>orderList</c> <c>add</c> ops and
|
||||
/// record each into the correct side's map. <c>isSelf:1</c> → the sender's own token (<paramref
|
||||
/// name="from"/>); <c>isSelf:0</c> → a cross-side gift living at that idx in the OPPONENT's index
|
||||
/// space (<paramref name="other"/>) — <c>isSelf</c> is the sender's perspective tag on
|
||||
/// <c>CardObj.IsPlayer</c> (RegisterToken.cs:22), and a card has a single <c>CardObj.Index</c>, so
|
||||
/// the gifted idx is the same slot in the recipient's own map (the one consulted when the recipient
|
||||
/// later plays it). Shared by <c>PlayActionsHandler</c> and <c>EchoHandler</c> — an Echo's orderList
|
||||
/// carries the same add-op shape (<c>SendCardDataMaker.MakeEchoData</c>), so both mine identically;
|
||||
/// Echo is mined but never relayed.</summary>
|
||||
public void RecordTokensFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
|
||||
{
|
||||
foreach (var (idx, cardId, isSelf) in KnownListBuilder.MineAddOps(orderList))
|
||||
RecordToken(isSelf == 1 ? from : other, idx, cardId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.BattleNode.Sessions.Dispatch.Handlers;
|
||||
|
||||
/// <summary>Echo is the receiver's per-frame ack; the client has no inbound Echo handler, so the
|
||||
/// node consumes it (bullet-2 audit). Relaying would risk an echo->echo storm.</summary>
|
||||
/// 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>
|
||||
internal sealed class EchoHandler : IFrameHandler
|
||||
{
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx) => Array.Empty<DispatchRoute>();
|
||||
public IReadOnlyList<DispatchRoute> Handle(FrameDispatchContext ctx)
|
||||
{
|
||||
if (ctx.Type == BattleType.Pvp && ctx.BothAfterReady())
|
||||
{
|
||||
var orderList = (ctx.Env.Body as RawBody)?.Entries.GetValueOrDefault("orderList");
|
||||
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
|
||||
}
|
||||
return Array.Empty<DispatchRoute>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,10 @@ internal sealed class PlayActionsHandler : IFrameHandler
|
||||
|
||||
var orderList = entries.GetValueOrDefault("orderList");
|
||||
|
||||
// Mine generated-token identities from this frame's add ops into the sender's idx->cardId
|
||||
// map, so a token played in a LATER frame resolves its cardId (bullet-3 audit F1).
|
||||
foreach (var (idx, cardId) in KnownListBuilder.MineAddOps(orderList))
|
||||
ctx.State.RecordToken(ctx.From, idx, cardId);
|
||||
// Mine generated-token identities from this frame's add ops into the right side's idx->cardId
|
||||
// map (isSelf:1 → sender; isSelf:0 → opponent, a cross-side gift), so a token played in a LATER
|
||||
// frame resolves its cardId — by whichever side ends up playing it (bullet-3 audit F1).
|
||||
ctx.State.RecordTokensFrom(ctx.From, ctx.Other, orderList);
|
||||
|
||||
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
|
||||
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList);
|
||||
|
||||
@@ -42,17 +42,19 @@ internal static class KnownListBuilder
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Mine generated-token identities from the sender's <c>add</c> ops: yields
|
||||
/// <c>(idx, cardId)</c> for every idx in each <c>{add:{idx:[...], isSelf:1, card:{cardId}}}</c>
|
||||
/// op. Skips <c>isSelf:0</c> adds (cross-side gifts — belong in the other side's map, deferred)
|
||||
/// and any add whose <c>card</c> has no concrete <c>cardId</c> — choice tokens
|
||||
/// (<c>card:{candidates}</c>, <c>RegisterChoiceAdd</c>), copy tokens (<c>card:{baseIdx}</c>,
|
||||
/// <c>RegisterCopyToken</c>), and private-group adds (string <c>idx</c>) — all deferred and all
|
||||
/// caught by the <c>cardId</c>-key / <c>idx</c>-is-list guards. This is the only place a
|
||||
/// freshly-generated card's identity exists on the wire (bullet-3 audit F1; producing code
|
||||
/// <c>RegisterToken</c>/<c>RegisterActionBase</c>) — the played-card op itself never carries a
|
||||
/// <c>cardId</c>.</summary>
|
||||
public static IEnumerable<(int Idx, long CardId)> MineAddOps(object? orderList)
|
||||
/// <summary>Mine generated-token identities from a sender's <c>add</c> ops: yields
|
||||
/// <c>(idx, cardId, isSelf)</c> for every idx in each <c>{add:{idx:[...], isSelf, card:{cardId}}}</c>
|
||||
/// op. <c>isSelf</c> is surfaced verbatim (the sender's perspective tag on <c>CardObj.IsPlayer</c>,
|
||||
/// <c>RegisterToken.cs:22</c>) so the caller can route the identity into the correct side's map —
|
||||
/// <c>isSelf:1</c> = the sender's own token, <c>isSelf:0</c> = a cross-side gift living at this idx
|
||||
/// in the OPPONENT's index space (<see cref="BattleSessionState.RecordTokensFrom"/>). Skips any add
|
||||
/// whose <c>card</c> has no concrete <c>cardId</c> — choice tokens (<c>card:{candidates}</c>,
|
||||
/// <c>RegisterChoiceAdd</c>), copy tokens (<c>card:{baseIdx}</c>, <c>RegisterCopyToken</c>), and
|
||||
/// private-group adds (string <c>idx</c>) — all deferred and all caught by the <c>cardId</c>-key /
|
||||
/// <c>idx</c>-is-list guards. This is the only place a freshly-generated card's identity exists on
|
||||
/// the wire (bullet-3 audit F1; producing code <c>RegisterToken</c>/<c>RegisterActionBase</c>) —
|
||||
/// the played-card op itself never carries a <c>cardId</c>.</summary>
|
||||
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineAddOps(object? orderList)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||
foreach (var op in ops)
|
||||
@@ -61,7 +63,7 @@ internal static class KnownListBuilder
|
||||
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
||||
|
||||
add.TryGetValue("isSelf", out var isSelfRaw);
|
||||
if (AsLong(isSelfRaw) != 1) continue; // own tokens only; cross-side gifts deferred
|
||||
var isSelf = (int)AsLong(isSelfRaw);
|
||||
|
||||
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
||||
if (!card.TryGetValue("cardId", out var cardIdRaw)) continue; // candidates/isChoice → no identity yet
|
||||
@@ -69,7 +71,7 @@ internal static class KnownListBuilder
|
||||
|
||||
if (!add.TryGetValue("idx", out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
|
||||
foreach (var i in idxList)
|
||||
yield return ((int)AsLong(i), cardId);
|
||||
yield return ((int)AsLong(i), cardId, isSelf);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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?>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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) }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user