refactor(battlenode): retire spellboost bookkeeping, engine owns cost+spellboost (M-HC-3)

The headless engine accumulates spell-charge for real on the receive path
(each spell play runs the played card's own AddSpellChargeCount) and resolves
the discounted cost by construction, so the wire-derived spellboost-count
bookkeeping is redundant. Engine-source the knownList spellboost COUNT too
(prod-faithful) via a new SessionBattleEngine.PlayedCardSpellboost, using the
same persist-post-play zone search as PlayedCardCost (SpellChargeCount survives
PlayCard; only ctor/ReturnCard zero it).

- Delete IdxToSpellboost/SpellboostMap/GetSpellboostMap/RecordSpellboostFrom
  (BattleSessionState) and MineAlterSpellboosts (KnownListBuilder); token/choice/
  copy identity maps are untouched.
- BuildPlayedCard takes an engine-sourced spellboost int (drops spellboostMap).
- Seed BattleLogManager fusion lists headless (the per-frame filter cleanup
  NREs on null EnemyFusionCard when a fanfare card registers a CalledCreateFilter)
  so real spell-charge grantor plays resolve.
- Add committed real-charge regression tests (no SeedHandCardSpellboostCost seam):
  one grantor play accumulates +1 on the reducer -> cost 5->4, count 1, persisting
  post-play; handler emits cost 4 + spellboost 1 engine-sourced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-06-06 21:48:50 -04:00
parent 51419d15cd
commit 0d7136787a
9 changed files with 261 additions and 194 deletions

View File

@@ -526,4 +526,174 @@ public class HeadlessConductorTests
Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost),
"non-vacuity: the emitted cost must NOT be the un-discounted base cost");
}
// === M-HC-3b: REAL spell-charge accumulation (no seam) =======================================
// The spellboost GRANTOR 118311030: a cost-3 follower whose when_play spell_charge skill
// (add_charge=1, target character=me&target=hand&card_type=all) adds +1 spell-charge to EVERY card in
// the caster's hand on each play. Drives the reducer's charge for real headless — no SeedHandCardSpellboostCost
// seam. (Its authored SECOND charge skill, add_charge=5, does NOT fire headless — only +1 lands per play;
// recorded as a known fidelity follow-up, irrelevant to this regression which needs only the +1.)
private const long SpellboostGrantorId = 118311030;
// A deck of alternating reducers + grantors so both reliably populate the opening hand and early draws
// (a single front-loaded reducer would shuffle out of reach). 15 of each = 30.
private static IReadOnlyList<long> ReducerAndGrantorDeck()
{
var deck = new List<long>(30);
for (int i = 0; i < 15; i++) { deck.Add(SpellboostReducerId); deck.Add(SpellboostGrantorId); }
return deck;
}
// Find the engine Index of the first hand card on seat A with the given wire cardId (the hand is
// shuffled, so we locate by identity, not position). -1 if not present.
private static int FindHandIdxByCardId(NodeNativeBattleHarness harness, long cardId)
{
for (int i = 0; i < harness.HandCount(playerSeat: true); i++)
if (harness.HandCardId(playerSeat: true, i) == (int)cardId)
return harness.HandCardIndex(playerSeat: true, i);
return -1;
}
// Ramp seat A to its turn `targetTurn` by alternating TurnStart/TurnEnd A/B; leaves seat A's turn OPEN.
private void RampToSeatATurn(NodeNativeBattleHarness harness, int targetTurn)
{
bool seatA = true;
while (true)
{
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: seatA).Accepted,
Is.True, "TurnStart");
if (seatA && harness.Turn(playerSeat: true) == targetTurn) return;
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: seatA).Accepted,
Is.True, "TurnEnd");
seatA = !seatA;
}
}
[Test]
public void Real_spell_charge_drops_engine_cost_and_count_no_seam()
{
// The committed M-HC-3b closure guard: drive a REAL spell-charge sequence headless (NO
// SeedHandCardSpellboostCost seam) and assert the engine-sourced COST and SPELLBOOST COUNT the node
// now emits are both correct by construction. Proves the retired wire-derived bookkeeping is
// redundant: the engine accumulates the charge itself (each grantor play runs the reducer's own
// AddSpellChargeCount) and resolves the discount.
using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerAndGrantorDeck());
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
// Ramp to seat A turn 3 (PP 3) so the cost-3 grantor is affordable.
RampToSeatATurn(harness, targetTurn: 3);
Assert.That(harness.Pp(playerSeat: true), Is.EqualTo(3), "seat A PP at turn 3");
// Locate a reducer + a grantor in the (shuffled) hand by identity.
int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId);
int grantorIdx = FindHandIdxByCardId(harness, SpellboostGrantorId);
Assert.That(reducerIdx, Is.GreaterThan(0), "a reducer must be in seat A's opening hand");
Assert.That(grantorIdx, Is.GreaterThan(0), "a grantor must be in seat A's opening hand");
// PRE-CHARGE non-vacuity: the reducer resolves to its BASE cost (5) and 0 charge BEFORE any grant.
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1),
Is.EqualTo(SpellboostReducerBaseCost), "reducer cost is base (5) before any charge");
Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1),
Is.EqualTo(0), "reducer spell-charge is 0 before any grant");
// Play the grantor (cost 3). Its when_play spell_charge adds +1 to every hand card — REAL engine
// resolution, no seam. This runs through the receive conductor (Push -> engine.Receive).
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(grantorIdx), isPlayerSeat: true).Accepted,
Is.True, "grantor play");
// THE engine-read assertions: the reducer (still in hand) now reads charge 1 and cost 4 (5 - 1) —
// accumulated for real by the engine, not seeded.
Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1),
Is.EqualTo(1), "one grantor play accumulates +1 real spell-charge on the reducer");
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1),
Is.EqualTo(SpellboostReducerBaseCost - 1),
"the engine resolves the reducer's cost down to 4 (base 5 - 1 charge), no seam");
// PERSIST-POST-PLAY proof (the read-moment this milestone chose): advance to seat A's next turn
// (fresh PP 4, affording the cost-4 reducer), play the reducer (a spell -> cemetery), and confirm
// PlayedCardSpellboost/PlayedCardCost STILL read 1/4 AFTER the card left the hand — i.e. the zone
// search reads the persisted count off the resolved card, no receive-capture needed.
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True);
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True);
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True);
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True);
Assert.That(harness.Pp(playerSeat: true), Is.GreaterThanOrEqualTo(4), "seat A fresh PP affords cost-4 reducer");
// The reducer's engine Index is stable across turns; play it now.
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(reducerIdx), isPlayerSeat: true).Accepted,
Is.True, "charged reducer play");
Assert.That(harness.Engine.PlayedCardSpellboost(playerSeat: true, reducerIdx, fallback: -1),
Is.EqualTo(1), "spell-charge persists on the played reducer (now in cemetery)");
Assert.That(harness.Engine.PlayedCardCost(playerSeat: true, reducerIdx, fallback: -1),
Is.EqualTo(SpellboostReducerBaseCost - 1),
"PlayedCost captured the discounted cost (4) at play time and persists post-play");
}
[Test]
public void Handler_emits_real_engine_spellboost_and_cost_on_knownList()
{
// The end-to-end emit payoff for M-HC-3b: a REAL-charged reducer played through the conductor, then
// PlayActionsHandler.Handle, with BOTH knownList[].cost AND knownList[].spellboost read straight off
// the engine (no wire-derived bookkeeping). Cost 4 (discounted) + count 1 (real charge).
using var harness = NodeNativeBattleHarness.Create(seatADeck: ReducerAndGrantorDeck());
Assert.That(harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true).Accepted, Is.True, "Deal");
Assert.That(harness.Push(NetworkBattleUri.Swap, SwapBody(), isPlayerSeat: true).Accepted, Is.True, "Swap");
Assert.That(harness.Push(NetworkBattleUri.Ready, ReadyBody(), isPlayerSeat: true).Accepted, Is.True, "Ready");
RampToSeatATurn(harness, targetTurn: 3);
int reducerIdx = FindHandIdxByCardId(harness, SpellboostReducerId);
int grantorIdx = FindHandIdxByCardId(harness, SpellboostGrantorId);
Assert.That(reducerIdx, Is.GreaterThan(0), "reducer in hand");
Assert.That(grantorIdx, Is.GreaterThan(0), "grantor in hand");
// Charge the reducer for real (one grantor play -> +1), then advance to a fresh seat A turn that
// affords the discounted reducer.
Assert.That(harness.Push(NetworkBattleUri.PlayActions, PlayBody(grantorIdx), isPlayerSeat: true).Accepted,
Is.True, "grantor play");
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: true).Accepted, Is.True);
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: false).Accepted, Is.True);
Assert.That(harness.Push(NetworkBattleUri.TurnEnd, TurnEndBody(), isPlayerSeat: false).Accepted, Is.True);
Assert.That(harness.Push(NetworkBattleUri.TurnStart, TurnStartBody(), isPlayerSeat: true).Accepted, Is.True);
// Ingest the reducer play into the engine (so PlayedCost/SpellChargeCount are captured at resolution).
var playBody = HandlerPlayBody(reducerIdx);
Assert.That(harness.Push(NetworkBattleUri.PlayActions, playBody, isPlayerSeat: true).Accepted,
Is.True, "charged reducer play ingest");
// Build the dispatch context the way BattleSession.BuildContext does; From == seat A (the sender).
harness.SeatA.Phase = HandshakePhase.AfterReady;
harness.SeatB.Phase = HandshakePhase.AfterReady;
var env = new MsgEnvelope(
NetworkBattleUri.PlayActions, ViewerId: harness.SeatA.ViewerId, Uuid: "udid-test", Bid: null,
RetryAttempt: 0, Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
Body: new RawBody(playBody));
var ctx = new FrameDispatchContext
{
A = harness.SeatA, B = harness.SeatB, From = harness.SeatA, Other = harness.SeatB,
Env = env, BattleId = "test-battle", State = harness.State, Engine = harness.Engine,
};
var routes = new PlayActionsHandler().Handle(ctx);
Assert.That(routes, Has.Count.EqualTo(1), "one route to the opponent");
var body = routes[0].Frame.Body as PlayActionsBroadcastBody;
Assert.That(body, Is.Not.Null, "frame body is a PlayActionsBroadcastBody");
Assert.That(body!.KnownList, Is.Not.Null.And.Count.EqualTo(1), "one knownList entry (the played reducer)");
Assert.That(body.KnownList![0].CardId, Is.EqualTo(SpellboostReducerId), "the reducer's identity");
// THE assertions: cost is the engine-resolved DISCOUNTED cost (4), spellboost is the REAL count (1).
Assert.That(body.KnownList[0].Cost, Is.EqualTo(SpellboostReducerBaseCost - 1),
"knownList[].cost must be the engine-resolved discounted cost (4), not base (5)");
Assert.That(body.KnownList[0].Spellboost, Is.EqualTo(1),
"knownList[].spellboost must be the REAL engine-accumulated charge count (1), engine-sourced");
// Non-vacuity: neither field is the un-charged default.
Assert.That(body.KnownList[0].Cost, Is.Not.EqualTo(SpellboostReducerBaseCost),
"non-vacuity: emitted cost is NOT the un-discounted base cost");
Assert.That(body.KnownList[0].Spellboost, Is.Not.EqualTo(0),
"non-vacuity: emitted spellboost is NOT 0");
}
}

View File

@@ -139,6 +139,13 @@ internal sealed class NodeNativeBattleHarness : IDisposable
/// Play frame would carry to play it).</summary>
public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos);
/// <summary>The wire CardId of the hand card at <paramref name="handPos"/> on the given seat. Lets a
/// test find a specific card (e.g. the spellboost reducer) in a shuffled opening hand by identity.</summary>
public int HandCardId(bool playerSeat, int handPos) => Engine.HandCardId(playerSeat, handPos);
/// <summary>The engine Index of the hand card at <paramref name="handPos"/> on the given seat.</summary>
public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos);
/// <summary>The real wire <c>CardId</c> of the in-play follower at <paramref name="boardPos"/> on the
/// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity
/// (M-HC-2).</summary>