feat(battlenode): emit engine-resolved cost on every knownList entry (M-HC-3)

The opponent-facing PlayActions knownList now carries the engine-RESOLVED
play-time cost (KnownCardEntry.cost), sourced from the headless shadow engine's
PlayedCost on the just-resolved card. This closes the spellboost cost-desync BY
CONSTRUCTION: the engine already knows the true discounted cost (spellboost +
board modifiers folded in), so no bookkeeping is needed.

- DTO: add non-nullable cost to KnownCardEntry (prod emits cost 45/45).
- SessionBattleEngine.PlayedCardCost(seat, idx, fallback): finds the resolved
  card by engine Index across in-play/cemetery/hand zones and returns PlayedCost
  (captured by PlayCard at resolution == discounted Cost), degrading to fallback
  when the engine is not owned/ready.
- PlayActionsHandler sources the played card's cost from ctx.Engine (ShadowIngest
  already resolved the play before the handler runs). Spellboost-map plumbing
  stays for now; Task 6 (M-HC-3b) retires it.
- Validation: engine-read test (charge-seeded reducer 101314020: base 5, cost
  5/1/0 at charge 0/4/5) + handler-emit test asserting knownList[0].cost == 1
  (discounted, not base 5) with non-vacuity. Board-dependent (when_evolve_other)
  case deferred to M-HC-4 (evolve not yet headless); cost is read off the resolved
  engine so board modifiers are captured by construction once their ops resolve.
- Harness: promote alt vanilla follower id (101211120) to AltVanillaFollowerId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 21:18:29 -04:00
parent b73f0f7157
commit 51419d15cd
7 changed files with 299 additions and 15 deletions

View File

@@ -16,17 +16,20 @@ internal static class KnownListBuilder
/// idx → 0. Prod sends the real count here and the client reads it straight into the card's cost model
/// (<c>NetworkBattleReceiver</c> spellboost case), so a wrong value makes the opponent compute the
/// card at full price and silently reject the play in <c>OperateReceiveChecker.IsPlayCard</c>
/// (PP-over → ConductError → NullOperationCollection → no render/echo). attachTarget stays "";
/// cost/clan/tribe remain deferred (receiver re-derives from cardId).</summary>
/// (PP-over → ConductError → NullOperationCollection → no render/echo). <paramref name="cost"/> is the
/// engine-RESOLVED play-time cost (M-HC-3a) the handler reads off the shadow engine and passes in;
/// it lands on the entry verbatim (a vanilla play naturally resolves to its base cost). attachTarget
/// stays ""; clan/tribe remain deferred (receiver re-derives from cardId).</summary>
public static KnownCardEntry? BuildPlayedCard(
IReadOnlyDictionary<int, long> deckMap, int playIdx, object? orderList,
IReadOnlyDictionary<int, int>? spellboostMap = null)
IReadOnlyDictionary<int, int>? spellboostMap = null, int cost = 0)
{
if (!deckMap.TryGetValue(playIdx, out var cardId)) return null;
var to = ExtractMoveTo(orderList, playIdx);
if (to is null) return null;
var spellboost = spellboostMap is not null && spellboostMap.TryGetValue(playIdx, out var sb) ? sb : 0;
return new KnownCardEntry(Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: spellboost, AttachTarget: "");
return new KnownCardEntry(
Idx: playIdx, CardId: cardId, To: to.Value, Spellboost: spellboost, AttachTarget: "", Cost: cost);
}
/// <summary>Mine spellboost-count changes from a sender's <c>orderList</c> <c>alter</c> ops. For each