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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user