feat(battle-node): resolve copy-token cardIds from baseIdx (pure)
KnownListBuilder.MineCopyTokens resolves a copy add's baseIdx against the actor's own idx->cardId map (self/other by isSelf), yielding (idx,cardId, isSelf). Skips concrete/choice adds, string (private-group) baseIdx, and unknown sources (degrade). Third token-reveal slice. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -130,6 +130,47 @@ internal static class KnownListBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mine copy/clone-token identities: for each copy <c>add</c> op
|
||||
/// (<c>{idx:[...], isSelf, card:{baseIdx, isPremium}}</c>), resolve its cardId from the appropriate
|
||||
/// side's idx->cardId map. The copied card lives at <c>baseIdx</c> in the actor's OWN index space —
|
||||
/// <c>RegisterCopyToken</c> is emitted only for <c>!IsReferenceOpponenCard</c>
|
||||
/// (<c>NetworkBattleManagerBase.cs:1106</c>); a cross-side copy sends a concrete <c>cardId</c> via a
|
||||
/// plain <c>RegisterToken</c> instead (handled by <see cref="MineAddOps"/>). Yields
|
||||
/// <c>(idx, cardId, isSelf)</c> — same shape as <see cref="MineAddOps"/>, routed by the same
|
||||
/// <see cref="BattleSessionState.RecordTokensFrom"/> rule: <c>isSelf:1</c> resolves+records into the
|
||||
/// sender's map (<paramref name="selfMap"/>), <c>isSelf:0</c> into the opponent's
|
||||
/// (<paramref name="otherMap"/>). Skips an add with a concrete <c>cardId</c> (→ MineAddOps), one with
|
||||
/// <c>candidates</c> (→ MineChoicePicks), a <c>string</c> <c>baseIdx</c> (private-group copy,
|
||||
/// <c>RegisterCopyToken.cs:19-22</c>), and a <c>baseIdx</c> absent from the chosen map (unknown source
|
||||
/// → degrade, no desync). <c>isPremium</c> (IsFoil) is cosmetic and ignored.</summary>
|
||||
public static IEnumerable<(int Idx, long CardId, int IsSelf)> MineCopyTokens(
|
||||
object? orderList,
|
||||
IReadOnlyDictionary<int, long> selfMap,
|
||||
IReadOnlyDictionary<int, long> otherMap)
|
||||
{
|
||||
if (orderList is not IEnumerable<object?> ops) yield break;
|
||||
foreach (var op in ops)
|
||||
{
|
||||
if (op is not IDictionary<string, object?> opDict) continue;
|
||||
if (!opDict.TryGetValue("add", out var addRaw) || addRaw is not IDictionary<string, object?> add) continue;
|
||||
|
||||
if (!add.TryGetValue("card", out var cardRaw) || cardRaw is not IDictionary<string, object?> card) continue;
|
||||
if (card.ContainsKey("cardId")) continue; // concrete token → MineAddOps
|
||||
if (!card.TryGetValue("baseIdx", out var baseRaw)) continue; // not a copy (candidates → MineChoicePicks)
|
||||
if (baseRaw is string) continue; // private-group copy → string baseIdx, skip
|
||||
var baseIdx = (int)AsLong(baseRaw);
|
||||
|
||||
add.TryGetValue("isSelf", out var isSelfRaw);
|
||||
var isSelf = (int)AsLong(isSelfRaw);
|
||||
var map = isSelf == 1 ? selfMap : otherMap;
|
||||
if (!map.TryGetValue(baseIdx, out var cardId)) continue; // unknown source → degrade
|
||||
|
||||
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, isSelf);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Map an inbound keyAction (the active player's send) to the opponent-facing list:
|
||||
/// for each Choice(1)/HaveBeforeSkillChoice(5) entry, keep <c>{type,cardId}</c> and drop
|
||||
/// <c>selectCard</c> when its <c>open==0</c> (hidden draw-to-hand pick stays secret), pass it
|
||||
|
||||
@@ -336,4 +336,90 @@ public class KnownListBuilderTests
|
||||
Assert.That(KnownListBuilder.StripKeyActionForOpponent(null), Is.Null);
|
||||
Assert.That(KnownListBuilder.StripKeyActionForOpponent(new List<object?>()), Is.Null);
|
||||
}
|
||||
|
||||
// A copy add op as it arrives in a RawBody: { "add": { "idx":[..], "isSelf":n, "card":{ "baseIdx":m, "isPremium":0 } } }
|
||||
private static Dictionary<string, object?> CopyOp(long[] idxs, long baseIdx, long isSelf = 1) => new()
|
||||
{
|
||||
["add"] = new Dictionary<string, object?>
|
||||
{
|
||||
["idx"] = idxs.Select(i => (object?)i).ToList(),
|
||||
["isSelf"] = isSelf,
|
||||
["card"] = new Dictionary<string, object?> { ["baseIdx"] = baseIdx, ["isPremium"] = 0L },
|
||||
}
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void MineCopyTokens_resolves_baseIdx_against_selfMap_for_isSelf_1()
|
||||
{
|
||||
var orderList = new List<object?> { CopyOp(new[] { 31L }, baseIdx: 5L, isSelf: 1) };
|
||||
var selfMap = new Dictionary<int, long> { [5] = 100_011_010L };
|
||||
var otherMap = new Dictionary<int, long>();
|
||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
|
||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 100_011_010L, 1) }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MineCopyTokens_resolves_baseIdx_against_otherMap_for_isSelf_0()
|
||||
{
|
||||
// Cross-side copy shape (battle-traffic_tk2_regular.ndjson:196 is an isSelf:0 Echo, baseIdx 21):
|
||||
// the source lives in the OPPONENT's index space, so resolve against otherMap and record there.
|
||||
var orderList = new List<object?> { CopyOp(new[] { 49L }, baseIdx: 21L, isSelf: 0) };
|
||||
var selfMap = new Dictionary<int, long>();
|
||||
var otherMap = new Dictionary<int, long> { [21] = 900_841_330L };
|
||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, otherMap).ToList();
|
||||
Assert.That(mined, Is.EquivalentTo(new[] { (49, 900_841_330L, 0) }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MineCopyTokens_skips_copy_when_baseIdx_absent_from_map()
|
||||
{
|
||||
// Unknown source (e.g. a card the node never recorded) → no record, no desync, the play degrades.
|
||||
var orderList = new List<object?> { CopyOp(new[] { 31L }, baseIdx: 99L, isSelf: 1) };
|
||||
Assert.That(
|
||||
KnownListBuilder.MineCopyTokens(orderList, new Dictionary<int, long>(), new Dictionary<int, long>()),
|
||||
Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MineCopyTokens_ignores_concrete_and_choice_adds()
|
||||
{
|
||||
// A concrete-cardId add is MineAddOps' job; a candidates add is MineChoicePicks' — both skipped here.
|
||||
var 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_111_010L } } },
|
||||
new Dictionary<string, object?> { ["add"] = new Dictionary<string, object?>
|
||||
{ ["idx"] = new List<object?> { 32L }, ["isSelf"] = 1L,
|
||||
["card"] = new Dictionary<string, object?> { ["candidates"] = new List<object?> { 1L, 2L } },
|
||||
["isChoice"] = "1" } },
|
||||
};
|
||||
var map = new Dictionary<int, long> { [1] = 5L };
|
||||
Assert.That(KnownListBuilder.MineCopyTokens(orderList, map, map), Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MineCopyTokens_skips_string_baseIdx_private_group()
|
||||
{
|
||||
// PrivateGroupIndexMsg != "" makes baseIdx a STRING (RegisterCopyToken.cs:19-22) — the hidden
|
||||
// private-card path; skipped just like private-group idx in MineAddOps.
|
||||
var 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?> { ["baseIdx"] = "g1", ["isPremium"] = 0L } } },
|
||||
};
|
||||
Assert.That(
|
||||
KnownListBuilder.MineCopyTokens(orderList, new Dictionary<int, long>(), new Dictionary<int, long>()),
|
||||
Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MineCopyTokens_yields_for_every_idx_in_a_multi_idx_copy_op()
|
||||
{
|
||||
var orderList = new List<object?> { CopyOp(new[] { 31L, 32L }, baseIdx: 5L, isSelf: 1) };
|
||||
var selfMap = new Dictionary<int, long> { [5] = 700L };
|
||||
var mined = KnownListBuilder.MineCopyTokens(orderList, selfMap, new Dictionary<int, long>()).ToList();
|
||||
Assert.That(mined, Is.EquivalentTo(new[] { (31, 700L, 1), (32, 700L, 1) }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user