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

@@ -36,15 +36,19 @@ public sealed record SelectCardEntry(
[property: JsonPropertyName("open")]
[property: JsonConverter(typeof(JsonNumberEnumConverter<ChoiceVisibility>))] ChoiceVisibility Open);
/// <summary>One revealed card in a <c>knownList</c>. Vanilla slice fills cardId from the sender's
/// deck map and leaves spellboost 0 / attachTarget "" (cost/clan/tribe deferred to the card-master
/// port — the receiver re-derives them from cardId).</summary>
/// <summary>One revealed card in a <c>knownList</c>. <c>cardId</c> from the sender's deck map; <c>cost</c>
/// is the ENGINE-RESOLVED play-time cost (M-HC-3a) — the discounted cost the headless engine actually
/// charged (spellboost + board modifiers folded in by construction), emitted on EVERY entry (prod sends
/// cost 45/45 in captures, so it is NOT omitted). <c>spellboost</c> still carries the count for now
/// (Task 6 retires that bookkeeping once cost is engine-sourced everywhere). attachTarget stays "";
/// clan/tribe remain deferred (receiver re-derives them from cardId).</summary>
public sealed record KnownCardEntry(
[property: JsonPropertyName("idx")] int Idx,
[property: JsonPropertyName("cardId")] long CardId,
[property: JsonPropertyName("to")] int To,
[property: JsonPropertyName("spellboost")] int Spellboost,
[property: JsonPropertyName("attachTarget")] string AttachTarget);
[property: JsonPropertyName("attachTarget")] string AttachTarget,
[property: JsonPropertyName("cost")] int Cost);
/// <summary>Renamed <c>targetList</c> entry. <c>isSelf</c> is actor-relative and passes through
/// verbatim — no perspective flip (bullet-3 audit F2).</summary>

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));

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

View File

@@ -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);