refactor(battlenode): engine-first token identity (cardId); keep wire-mining fallback (M-HC-4f, partial)

Source the played card's opponent-facing knownList[].cardId off the shadow engine
(SessionBattleEngine.PlayedCardId -> BattleCardBase.CardId), engine-first with the
wire-mined idx->cardId map as the fallback. PROVEN engine-resolved (each backed by a
HeadlessConductorTests PlayedCardId_* test): deck cards and receive-path substituted/
revealed tokens (engine seats the wire id at the wire idx).

PARTIAL retirement: the wire-mining bookkeeping (MineAddOps/MineChoicePicks/MineCopyTokens
+ Record*From) is KEPT as the load-bearing fallback. The choice/Discover, copy/clone and
cross-side (isSelf:0) token cases are NOT proven to resolve at a wire idx headless — the
autonomous token_draw path seats a chosen token at engine Index 0 (would collide with the
leader), and copy/cross-side aren't cheaply fixturable. Deleting their mining on faith
would silently corrupt opponent reveals, so it stays behind a TODO(M-HC-4f) gate.

- SessionBattleEngine.PlayedCardId: new accessor mirroring PlayedCardClan/Tribe.
- BuildPlayedCard: signature deckMap->explicit cardId; null on cardId==0 (no engine id AND
  no mined/deck-map fallback).
- PlayActionsHandler: cardId = engine.PlayedCardId(seat, idx, fallback: mapped) ; mining retained.
- Tests: PlayedCardId_* (deck/substituted/degrade pass; choice-gap [Explicit] documents the
  Index-0 finding). KnownListBuilder + CaptureConformance call-sites updated to new signature.

Full BattleNode suite 263/263 green; HeadlessConductorTests 27/27; drift clean; no Engine edits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-07 00:36:49 -04:00
parent d3508d7bd4
commit a30a496265
8 changed files with 219 additions and 52 deletions

View File

@@ -10,23 +10,29 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
internal static class KnownListBuilder
{
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
/// (token idx not in the deck map, or no matching move op). <paramref name="cost"/>,
/// <paramref name="spellboost"/>, <paramref name="clan"/> and <paramref name="tribe"/> are ALL
/// ENGINE-SOURCED (M-HC-3a/3b/4e) — the handler reads them off the shadow engine
/// (<c>SessionBattleEngine.PlayedCardCost</c>/<c>PlayedCardSpellboost</c>/<c>PlayedCardClan</c>/
/// <c>PlayedCardTribe</c>) and passes them in; all land on the entry verbatim. The wire-derived
/// spellboost bookkeeping is retired — the engine owns cost and count by construction (cost folds the
/// spellboost discount in already; the count rides the entry only to stay prod-faithful, prod sends the
/// real count here). <paramref name="clan"/>/<paramref name="tribe"/> are the LIVE (skill-applied)
/// values the engine resolved — prod always sends both on every knownList entry (clan int, tribe the
/// comma-joined int string, "0" for none). Prod's client reads cost straight into the card's cost model
/// (<c>NetworkBattleReceiver</c>), so a vanilla play resolves to its base cost and count 0. attachTarget
/// stays "".</summary>
/// (the played idx resolves to no card identity, or there's no matching move op). <paramref name="cardId"/>,
/// <paramref name="cost"/>, <paramref name="spellboost"/>, <paramref name="clan"/> and <paramref name="tribe"/>
/// are ALL ENGINE-SOURCED at the call site (M-HC-3a/3b/4e/4f) — the handler reads them off the shadow engine
/// (<c>SessionBattleEngine.PlayedCardId</c>/<c>PlayedCardCost</c>/<c>PlayedCardSpellboost</c>/<c>PlayedCardClan</c>/
/// <c>PlayedCardTribe</c>) and passes them in; all land on the entry verbatim.
/// <para><paramref name="cardId"/> is the engine-resolved TRUE identity of the played card (M-HC-4f). The
/// handler computes it engine-first with a fallback: <c>engine.PlayedCardId(seat, playIdx, fallback: deckMapId)</c>,
/// where <c>deckMapId</c> is the wire-mined idx→cardId entry (deck card or recorded token). So an engine-backed
/// session emits the engine identity; a non-engine session (or an idx the engine doesn't headlessly resolve at a
/// wire idx — choice/copy/cross-side tokens, still wire-mined; see TODO(M-HC-4f) in PlayActionsHandler) falls back
/// to the mined map. A <paramref name="cardId"/> of 0 (no engine id AND no mined entry) means an un-synthesizable
/// play → null (no knownList; the play degrades to {playIdx,type}).</para>
/// The wire-derived spellboost bookkeeping is retired — the engine owns cost and count by construction (cost folds
/// the spellboost discount in already; the count rides the entry only to stay prod-faithful, prod sends the real
/// count here). <paramref name="clan"/>/<paramref name="tribe"/> are the LIVE (skill-applied) values the engine
/// resolved — prod always sends both on every knownList entry (clan int, tribe the comma-joined int string, "0"
/// for none). Prod's client reads cost straight into the card's cost model (<c>NetworkBattleReceiver</c>), so a
/// vanilla play resolves to its base cost and count 0. attachTarget stays "".</summary>
public static KnownCardEntry? BuildPlayedCard(
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList,
int playIdx, long cardId, object? orderList,
int cost = 0, int spellboost = 0, int clan = 0, string tribe = "0")
{
if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
if (cardId == 0) return null; // no engine id AND no mined/deck-map fallback → can't synthesize an identity
var to = ExtractMoveTo(orderList, playIdx);
if (to is null) return null;
return new KnownCardEntry(