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:
@@ -10,6 +10,7 @@ using BattlePlayerBase = engine::BattlePlayerBase;
|
||||
using BattleCardBase = engine::BattleCardBase;
|
||||
using ClassBattleCardBase = engine::ClassBattleCardBase;
|
||||
using CardCreatorBase = engine::CardCreatorBase;
|
||||
using CostAddModifier = engine::CostAddModifier;
|
||||
using SBattleLoad = engine::SBattleLoad;
|
||||
using CardTemplate = engine::CardTemplate;
|
||||
using GameObject = engine::UnityEngine.GameObject;
|
||||
@@ -169,6 +170,69 @@ internal sealed class SessionBattleEngine
|
||||
public int InPlayCardId(bool playerSeat, int boardPos) =>
|
||||
Seat(playerSeat).ClassAndInPlayCardList[boardPos + 1].CardId;
|
||||
|
||||
/// <summary>The engine-RESOLVED play-time cost of the card whose engine <c>Index</c> == <paramref name="idx"/>
|
||||
/// on <paramref name="playerSeat"/> (M-HC-3a). This is the discounted cost the play actually paid —
|
||||
/// spellboost reduction, board-dependent modifiers and all — read straight off the engine, so the
|
||||
/// opponent-facing knownList carries the SAME cost the engine charged (closing the spellboost
|
||||
/// cost-desync BY CONSTRUCTION: no bookkeeping, the engine already knows).
|
||||
/// <para>READ-MOMENT: the conductor's <c>ShadowIngest</c> runs <c>engine.Receive</c> (→ resolves the
|
||||
/// play) BEFORE the handler runs, so at read time the played card has LEFT the hand — a follower sits
|
||||
/// in <c>ClassAndInPlayCardList</c>, a spell in <c>CemeteryList</c>. <see cref="BattleCardBase.PlayCard"/>
|
||||
/// captures <c>_playedCost = useCost</c> (== the fully-resolved <c>Cost</c> at the moment of play,
|
||||
/// incl. every CostModifier) onto the card object, which persists after the card leaves the hand —
|
||||
/// so <see cref="BattleCardBase.PlayedCost"/> is the authoritative play-time discounted cost. We search
|
||||
/// the seat's post-resolution zones (in-play, cemetery) by <c>Index</c>, then fall back to the hand
|
||||
/// (a not-yet-resolved card, e.g. a degenerate test path) reading the live <c>Cost</c> there.</para>
|
||||
/// <para>Degrades to <paramref name="fallback"/> when the engine is not set up (the single-active-engine
|
||||
/// gate left this session without an owned engine) or the idx resolves to no card — so a non-engine
|
||||
/// session never crashes and a vanilla play simply emits its base cost via the caller's fallback.</para></summary>
|
||||
public int PlayedCardCost(bool playerSeat, int idx, int fallback = 0)
|
||||
{
|
||||
if (_mgr is null) return fallback;
|
||||
var card = FindByIndex(Seat(playerSeat), idx);
|
||||
if (card is null) return fallback;
|
||||
// PlayedCost is set (>= 0) once PlayCard resolved the play; before that (a card still in hand on a
|
||||
// degenerate path) read the live Cost, which already folds in any registered CostModifier.
|
||||
return card.PlayedCost >= 0 ? card.PlayedCost : card.Cost;
|
||||
}
|
||||
|
||||
// Locate the card with the given engine Index across the seat's post-resolution zones. Order matters
|
||||
// only for disambiguation; Index is unique per card so the first hit is the card. In-play (followers)
|
||||
// and cemetery (spells) are where a just-resolved play lands; hand is the pre-resolution fallback.
|
||||
private static BattleCardBase? FindByIndex(BattlePlayerBase seat, int idx)
|
||||
{
|
||||
foreach (var c in seat.ClassAndInPlayCardList)
|
||||
if (c.Index == idx) return c;
|
||||
foreach (var c in seat.CemeteryList)
|
||||
if (c.Index == idx) return c;
|
||||
foreach (var c in seat.HandCardList)
|
||||
if (c.Index == idx) return c;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>TEST SEAM (M-HC-3a validation): register a cost-reducing modifier on the hand card at
|
||||
/// engine <c>Index</c> == <paramref name="idx"/>, mimicking what card 101314020's <c>when_spell_charge</c>
|
||||
/// <c>cost_change add=ADD_CHARGE_COUNT*-1</c> skill does once it has accumulated <paramref name="charge"/>
|
||||
/// spellboost charges (each charge adds a <c>CostAddModifier(-1)</c>; the engine's own
|
||||
/// <see cref="Skill_cost_change"/> builds exactly this). Used to drive the count→cost resolution
|
||||
/// deterministically headless without pumping the (VFX-coupled) spell-charge skill chain through a
|
||||
/// real multi-spell sequence — the engine's authentic <see cref="BattleCardBase.Cost"/> getter then
|
||||
/// resolves the discount, and <see cref="BattleCardBase.PlayCard"/> captures it as PlayedCost on the
|
||||
/// next play. Returns the resolved hand-card Cost AFTER seeding (base − charge) for the caller to pin.
|
||||
/// No-op-returns -1 if the engine isn't set up or no hand card has that Index.</summary>
|
||||
internal int SeedHandCardSpellboostCost(bool playerSeat, int idx, int charge)
|
||||
{
|
||||
if (_mgr is null) return -1;
|
||||
BattleCardBase? card = null;
|
||||
foreach (var c in Seat(playerSeat).HandCardList)
|
||||
if (c.Index == idx) { card = c; break; }
|
||||
if (card is null) return -1;
|
||||
for (int i = 0; i < charge; i++)
|
||||
card.AddCostModifier(new CostAddModifier(-1), null, eventCall: false);
|
||||
card.SetSpellChargeCount(charge); // keep the charge count consistent with the modifiers (cosmetic here)
|
||||
return card.Cost;
|
||||
}
|
||||
|
||||
private engine::BattlePlayerBase Seat(bool playerSeat) =>
|
||||
(_mgr ?? throw new InvalidOperationException("read before Setup")).GetBattlePlayer(playerSeat);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user