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

@@ -39,11 +39,21 @@ internal sealed class PlayActionsHandler : IFrameHandler
ctx.State.RecordCopyTokensFrom(ctx.From, ctx.Other, orderList);
var deckMap = ctx.State.GetOrSeedDeckMap(ctx.From);
// Spellboost count rides the played card's knownList (prod-faithful; the client reads it into the
// card's cost model). Read the CURRENT map (state before this frame's grant) for the emit, then
// fold THIS frame's alter ops in afterwards — a card's cost is fixed as it leaves hand, and a play
// that grants spellboost (e.g. Fate's Hand) targets the REST of the hand, not the card just played.
var played = KnownListBuilder.BuildPlayedCard(deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From));
// The ENGINE-RESOLVED play-time cost (M-HC-3a). The conductor's ShadowIngest already ran
// engine.Receive for THIS frame before this handler runs, so the engine has resolved the play and
// PlayedCardCost reads the discounted cost it actually charged (spellboost + board modifiers folded
// in BY CONSTRUCTION — no bookkeeping). Sender's seat == ctx.A (BattleSession.ShadowIngest uses the
// same ReferenceEquals(from, A) mapping). Degrades to 0 when the engine isn't owned/ready for this
// session (single-active-engine gate) so a non-engine session never crashes.
bool senderSeat = ReferenceEquals(ctx.From, ctx.A);
int playedCost = ctx.Engine.PlayedCardCost(senderSeat, playIdx, fallback: 0);
// Spellboost count still rides the played card's knownList (prod-faithful; Task 6 retires this
// bookkeeping now that cost is engine-sourced). Read the CURRENT map (state before this frame's
// grant) for the emit, then fold THIS frame's alter ops in afterwards — a play that grants
// spellboost (e.g. Fate's Hand) targets the REST of the hand, not the card just played.
var played = KnownListBuilder.BuildPlayedCard(
deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From), cost: playedCost);
ctx.State.RecordSpellboostFrom(ctx.From, ctx.Other, orderList);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault(WireKeys.TargetList));