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

@@ -39,9 +39,12 @@ public sealed record SelectCardEntry(
/// <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>
/// cost 45/45 in captures, so it is NOT omitted). <c>spellboost</c> is now ALSO engine-sourced (M-HC-3b) —
/// the played card's accumulated spell-charge count read straight off the resolved engine
/// (<c>SessionBattleEngine.PlayedCardSpellboost</c>); the wire-derived spellboost bookkeeping is retired.
/// Cost already folds the discount in by construction; the count rides the entry only to stay prod-faithful
/// (prod sends the real count). 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,

View File

@@ -104,48 +104,6 @@ internal sealed class BattleSessionState
RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId);
}
/// <summary>Per-side idx-&gt;spellboost COUNT, accumulated from <c>orderList</c> <c>alter</c> ops via
/// <see cref="RecordSpellboostFrom"/>. Separate from <see cref="IdxToCardId"/> because spellboost is a
/// mutable counter, not an identity. Surfaced by <c>BuildPlayedCard</c> as the played card's
/// <c>knownList.spellboost</c> so the opponent computes its discounted cost (see that method).</summary>
public Dictionary<IBattleParticipant, Dictionary<int, int>> IdxToSpellboost { get; } = new();
private Dictionary<int, int> SpellboostMap(IBattleParticipant side)
{
if (!IdxToSpellboost.TryGetValue(side, out var map))
IdxToSpellboost[side] = map = new Dictionary<int, int>();
return map;
}
/// <summary>The side's idx-&gt;spellboost map (empty if nothing recorded yet). Read by
/// <c>PlayActionsHandler</c> to feed <c>BuildPlayedCard</c>.</summary>
public IReadOnlyDictionary<int, int> GetSpellboostMap(IBattleParticipant side) => SpellboostMap(side);
/// <summary>Apply a frame's spellboost <c>alter</c> ops to the per-side maps. Routed by <c>isSelf</c>
/// (the sender's perspective) exactly like <see cref="RecordTokensFrom"/>: <c>isSelf:1</c> → the
/// sender's own hand (<paramref name="from"/>); <c>isSelf:0</c> → the opponent's hand
/// (<paramref name="other"/>) for the rare cross-side spellboost. Ops: <c>'a'</c> add, <c>'s'</c> set,
/// <c>'h'</c> half. Call this AFTER <c>BuildPlayedCard</c> for the same frame: a card's cost is fixed
/// when it leaves hand, so the played card's emitted count must reflect state BEFORE this frame's
/// grant (Fate's Hand plays, then spellboosts the rest of the hand). Recorded only from the
/// authoritative PlayActions, never the Echo, to avoid double-counting the same alter.
/// Known gap: a card bounced back to hand keeps its stale count (no reset on zone-exit) — not yet
/// observed in capture, left for when a bounce desync actually shows up.</summary>
public void RecordSpellboostFrom(IBattleParticipant from, IBattleParticipant other, object? orderList)
{
foreach (var (idx, isSelf, op, amount) in KnownListBuilder.MineAlterSpellboosts(orderList))
{
var map = SpellboostMap(isSelf == CardOwner.Self ? from : other);
map.TryGetValue(idx, out var cur);
map[idx] = op switch
{
's' => amount, // set
'h' => cur / 2, // half
_ => cur + amount, // 'a' add (the only form seen in capture)
};
}
}
/// <summary>Mine + record copy/clone-token identities (<see cref="KnownListBuilder.MineCopyTokens"/>)
/// into the correct side's map. A copy's source lives at <c>baseIdx</c> in the actor's own index
/// space, so the resolution side == the record side, both selected by the same <c>isSelf</c> routing

View File

@@ -48,13 +48,16 @@ internal sealed class PlayActionsHandler : IFrameHandler
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.
// The spellboost (spell-charge) COUNT is now ALSO engine-sourced (M-HC-3b) — the wire-derived
// bookkeeping is retired. The engine accumulated the true count for the played card during the
// ShadowIngest's engine.Receive (each spell play runs the card's own AddSpellChargeCount), so
// PlayedCardSpellboost reads it straight off the resolved card (persist-post-play, same zone search
// as the cost). Cost already folds the discount in by construction; the count rides the entry only
// to stay prod-faithful (prod sends the real count). Same senderSeat mapping as the cost read.
int playedSpellboost = ctx.Engine.PlayedCardSpellboost(senderSeat, playIdx, fallback: 0);
var played = KnownListBuilder.BuildPlayedCard(
deckMap, playIdx, orderList, ctx.State.GetSpellboostMap(ctx.From), cost: playedCost);
ctx.State.RecordSpellboostFrom(ctx.From, ctx.Other, orderList);
deckMap, playIdx, orderList, cost: playedCost, spellboost: playedSpellboost);
var oppoTargets = KnownListBuilder.RenameTargets(entries.GetValueOrDefault(WireKeys.TargetList));
// Deck-sourced movements (fetch / search / summon-from-deck) ride the uList — a verbatim,

View File

@@ -10,58 +10,27 @@ namespace SVSim.BattleNode.Sessions.Dispatch;
internal static class KnownListBuilder
{
/// <summary>The played card's knownList entry, or null when its identity can't be synthesized
/// (token idx not in the deck map, or no matching move op). <paramref name="spellboostMap"/> supplies
/// the played card's spellboost COUNT (accumulated from prior <c>alter</c> ops via
/// <see cref="MineAlterSpellboosts"/> / <c>BattleSessionState.RecordSpellboostFrom</c>); absent/unmapped
/// 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). <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>
/// (token idx not in the deck map, or no matching move op). <paramref name="cost"/> and
/// <paramref name="spellboost"/> are both ENGINE-SOURCED (M-HC-3a/3b) — the handler reads the played
/// card's resolved play-time cost (<c>SessionBattleEngine.PlayedCardCost</c>) and accumulated
/// spell-charge count (<c>SessionBattleEngine.PlayedCardSpellboost</c>) off the shadow engine and passes
/// them in; both land on the entry verbatim. The wire-derived spellboost bookkeeping is retired —
/// the engine owns both cost and count by construction (cost folds the spellboost discount in already;
/// the count rides the entry only to stay prod-faithful, prod sends the real count here). Prod's client
/// reads cost straight into the card's cost model (<c>NetworkBattleReceiver</c>), so a vanilla play
/// resolves to its base cost and count 0. 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, int cost = 0)
int cost = 0, int spellboost = 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: "", Cost: cost);
}
/// <summary>Mine spellboost-count changes from a sender's <c>orderList</c> <c>alter</c> ops. For each
/// <c>{alter:{idx:[...], isSelf, spellboost:"&lt;op&gt;&lt;n&gt;"}}</c> op, yields
/// <c>(idx, isSelf, op, amount)</c> for every idx — <c>op</c> ∈ {<c>'a'</c> add, <c>'s'</c> set,
/// <c>'h'</c> half} (mirrors <c>RegisterAlter.ChangeType</c>; the leading letter on the value encodes
/// the operation, the rest is the integer amount). <c>isSelf</c> is the sender's perspective tag,
/// surfaced verbatim so the caller routes into the correct side's map (same rule as
/// <see cref="MineAddOps"/>). Skips alter ops with no <c>spellboost</c> key (an alter can also carry
/// cost/atk/etc.), a non-string or too-short value, an unparseable amount, or a non-list <c>idx</c>
/// (e.g. a private-group string idx). The only form seen in real captures is <c>"a1"</c> (each spell
/// play adds 1 to the listed hand cards); set/half are handled for completeness.</summary>
public static IEnumerable<(int Idx, CardOwner IsSelf, char Op, int Amount)> MineAlterSpellboosts(object? orderList)
{
if (orderList is not IEnumerable<object?> ops) yield break;
foreach (var op in ops)
{
if (op is not IDictionary<string, object?> opDict) continue;
if (!opDict.TryGetValue(WireKeys.Alter, out var alterRaw) || alterRaw is not IDictionary<string, object?> alter) continue;
if (!alter.TryGetValue(WireKeys.Spellboost, out var sbRaw) || sbRaw is not string sbStr || sbStr.Length < 2) continue;
var opChar = sbStr[0];
if (!int.TryParse(sbStr.AsSpan(1), out var amount)) continue;
alter.TryGetValue(WireKeys.IsSelf, out var isSelfRaw);
var isSelf = (CardOwner)(int)AsLong(isSelfRaw);
if (!alter.TryGetValue(WireKeys.Idx, out var idxRaw) || idxRaw is not IEnumerable<object?> idxList) continue;
foreach (var i in idxList)
yield return ((int)AsLong(i), isSelf, opChar, amount);
}
}
/// <summary>The <c>to</c> place-state of the FIRST <c>move</c> op whose <c>idx</c> list contains
/// <paramref name="playIdx"/> (the played card's own move; later add/alter ops are the deferred
/// token slice), or null if absent. NOTE: the sender-side <c>to</c> is passed through verbatim —

View File

@@ -28,6 +28,7 @@ using UIWidget = engine::UIWidget;
using UISprite = engine::UISprite;
using NullDetailPanelControl = engine::NullDetailPanelControl;
using DetailPanelControl = engine::DetailPanelControl;
using BattleLogManager = engine::Wizard.Battle.UI.BattleLogManager;
namespace SVSim.BattleNode.Sessions.Engine;
@@ -94,6 +95,7 @@ internal sealed class SessionBattleEngine
InitLeaderLife(mgr); // a 0-life leader reads as game-over and silently blocks plays
InitCardTemplates(mgr); // play/draw resolution touches the (no-op) card view layer
InitHeadlessViews(mgr); // turn/play cycle dereferences UI-container + emotion refs
SeedBattleLogManager(); // per-frame filter cleanup reads BattleLogManager fusion lists
InstallHeadlessNetworkAgent(); // turn-flow resolve reads ToolboxGame.RealTimeNetworkAgent
// Per-session leader class: chara_id == class_id for 1..8 in the all-8-class ClassCharacterList,
@@ -162,6 +164,11 @@ internal sealed class SessionBattleEngine
/// a card dealt from the seeded deck.</summary>
public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index;
/// <summary>The real <c>CardId</c> (wire identity) of the hand card at <paramref name="handPos"/>. Lets a
/// test locate a specific card in a SHUFFLED opening hand by identity (then read its <see cref="HandCardIndex"/>
/// to drive a play), without depending on which shuffled position the card landed at.</summary>
public int HandCardId(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].CardId;
/// <summary>The real <c>CardId</c> (wire identity) of the in-play follower at <paramref name="boardPos"/>
/// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as
/// <see cref="BoardCount"/>). Used to assert an opponent reveal seated the substituted card with its
@@ -196,6 +203,28 @@ internal sealed class SessionBattleEngine
return card.PlayedCost >= 0 ? card.PlayedCost : card.Cost;
}
/// <summary>The engine-RESOLVED spellboost (spell-charge) COUNT of the card whose engine <c>Index</c> ==
/// <paramref name="idx"/> on <paramref name="playerSeat"/> (M-HC-3b). The engine accumulates this count
/// for real on the receive path (each spell play that targets the card runs the card's own
/// <c>Skill_spell_charge.AddSpellChargeCount</c>), so this is the same authoritative count prod sends —
/// emitted on the opponent-facing knownList so the wire stays prod-faithful now that the wire-derived
/// spellboost bookkeeping is retired (cost itself is engine-sourced via <see cref="PlayedCardCost"/>).
/// <para>READ-MOMENT (persist-post-play): <see cref="BattleCardBase.SpellChargeCount"/> is set to 0 only
/// in the ctor (re-init, BattleCardBase.cs:2042) and in <c>ReturnCard</c> (bounce-to-hand,
/// BattleCardBase.cs:2681); <see cref="BattleCardBase.PlayCard"/> never touches it. So the count PERSISTS
/// on the played card object after it leaves the hand (follower in-play, spell in cemetery) — the same
/// persist-after-play property <see cref="BattleCardBase.PlayedCost"/> has. We therefore use the SAME
/// post-resolution zone search (<see cref="FindByIndex"/>: in-play → cemetery → hand) and read
/// <c>SpellChargeCount</c> directly — no separate receive-capture is needed.</para>
/// <para>Degrades to <paramref name="fallback"/> when the engine is not set up or the idx resolves to no
/// card — so a non-engine session never crashes and a vanilla play emits 0 via the caller's fallback.</para></summary>
public int PlayedCardSpellboost(bool playerSeat, int idx, int fallback = 0)
{
if (_mgr is null) return fallback;
var card = FindByIndex(Seat(playerSeat), idx);
return card?.SpellChargeCount ?? fallback;
}
// 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.
@@ -399,6 +428,19 @@ internal sealed class SessionBattleEngine
return card;
}
// The per-frame skill-filter cleanup (BattleManagerBase.RemoveUnUseCalledFilterDictionary, run on
// EVERY receive) reads BattleLogManager.GetInstance().EnemyFusionCard.Contains(...) when a card with a
// registered CalledCreateFilter is alive — e.g. a follower with a when_play spell_charge/fanfare skill
// (BattleManagerBase.cs:155). The shim BattleLogManager singleton leaves PlayerFusionCard/EnemyFusionCard
// null (no UI ran SetUp), so that .Contains NREs. Seed both to empty lists — a pure no-op view-state
// seed (the fusion log is cosmetic; nothing headless adds to it). Process-global like the other seeds.
private static void SeedBattleLogManager()
{
var log = BattleLogManager.GetInstance();
log.PlayerFusionCard ??= new List<BattleCardBase>();
log.EnemyFusionCard ??= new List<BattleCardBase>();
}
// The turn-flow + emit bookkeeping reads the global ToolboxGame.RealTimeNetworkAgent (e.g.
// RealTimeNetworkAgent.GetIsFirstPlayer/GetTurnState, which delegate to GameMgr's
// NetworkUserInfoData.TurnState; AddActionSequence touches _gungnir). Headless there is no socket

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>

View File

@@ -88,49 +88,6 @@ public class BattleSessionStateTests
Assert.That(a, Is.Not.EqualTo(b));
}
[Test]
public void RecordSpellboostFrom_accumulates_add_ops_and_routes_by_isSelf()
{
var state = new BattleSessionState(masterSeed: 1);
var caster = new StubParticipant(7, Ctx(900L));
var oppo = new StubParticipant(6, Ctx(901L));
// Two spell-plays each grant +1 to the caster's hand card idx 3 (the classic spellboost ramp).
var grant = new List<object?> { new Dictionary<string, object?>
{ ["alter"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["type"] = "add", ["spellboost"] = "a1" } } };
state.RecordSpellboostFrom(caster, oppo, grant);
state.RecordSpellboostFrom(caster, oppo, grant);
Assert.That(state.GetSpellboostMap(caster)[3], Is.EqualTo(2), "two +1 grants accumulate");
Assert.That(state.GetSpellboostMap(oppo).ContainsKey(3), Is.False, "isSelf:1 routes to the caster only");
}
[Test]
public void Boosted_card_carries_real_spellboost_into_its_knownList()
{
// End-to-end regression for the 2026-06-05 desync: a spellboosted card whose relayed knownList
// shipped spellboost:0 made the opponent compute full cost and silently reject the play. After the
// grant is recorded, BuildPlayedCard must emit the accumulated count, not 0.
var state = new BattleSessionState(masterSeed: 1);
var caster = new StubParticipant(7, Ctx(101311010L, 999L, 998L)); // idx 1..3 deck-seeded
var grant = new List<object?> { new Dictionary<string, object?>
{ ["alter"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["type"] = "add", ["spellboost"] = "a1" } } };
state.RecordSpellboostFrom(caster, new StubParticipant(6, Ctx(901L)), grant);
// idx 3 is now played hand->board; its knownList must reflect the +1.
var play = new List<object?> { new Dictionary<string, object?>
{ ["move"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["from"] = 10L, ["to"] = 20L } } };
var entry = KnownListBuilder.BuildPlayedCard(
state.GetOrSeedDeckMap(caster), playIdx: 3, orderList: play, state.GetSpellboostMap(caster));
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Spellboost, Is.EqualTo(1));
}
private static long[] DistinctDeck() =>
Enumerable.Range(1, 30).Select(i => 200_000_000L + i).ToArray();
}

View File

@@ -91,11 +91,23 @@ public class KnownListBuilderTests
// M-HC-3a: the handler reads the engine-resolved play-time cost and passes it in; BuildPlayedCard
// lands it on the entry verbatim. (A wrong cost yields a different field — non-vacuity.)
var deckMap = new Dictionary<int, long> { [3] = 101314020L };
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), spellboostMap: null, cost: 3);
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), cost: 3);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Cost, Is.EqualTo(3));
}
[Test]
public void BuildPlayedCard_emits_engine_sourced_spellboost_count()
{
// M-HC-3b: the handler reads the engine-resolved spell-charge count
// (SessionBattleEngine.PlayedCardSpellboost) and passes it in; BuildPlayedCard lands it on the
// entry verbatim. (Default 0 vs a non-zero value is the non-vacuity.)
var deckMap = new Dictionary<int, long> { [3] = 101314020L };
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), cost: 3, spellboost: 2);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Spellboost, Is.EqualTo(2));
}
[Test]
public void BuildPlayedCard_returns_null_for_token_idx_not_in_deck()
{
@@ -104,69 +116,15 @@ public class KnownListBuilderTests
Assert.That(entry, Is.Null);
}
// A spellboost alter op as it arrives in a RawBody: { "alter": { "idx": [..], "isSelf": n,
// "type": "add", "spellboost": "a1" } } — the value's leading letter is the op, the rest the amount.
private static List<object?> AlterSpellboostOp(long[] idxs, string value, long isSelf = 1) => new()
{
new Dictionary<string, object?>
{
["alter"] = new Dictionary<string, object?>
{
["idx"] = idxs.Select(i => (object?)i).ToList(),
["isSelf"] = isSelf, ["type"] = "add", ["spellboost"] = value,
}
}
};
[Test]
public void BuildPlayedCard_emits_spellboost_count_from_map()
public void BuildPlayedCard_defaults_spellboost_to_zero_when_caller_passes_none()
{
// The fix: a boosted card's knownList must carry its real count (prod sends 1/2/3), not 0.
// A vanilla play emits spellboost 0 (the engine resolves no spell-charge for a non-boosted card,
// so the handler's PlayedCardSpellboost read is 0 and the param defaults to 0).
var deckMap = new Dictionary<int, long> { [3] = 101311010L };
var spellboost = new Dictionary<int, int> { [3] = 2 };
var entry = KnownListBuilder.BuildPlayedCard(deckMap, playIdx: 3, orderList: OrderListMove(3, 10, 20), spellboost);
Assert.That(entry, Is.Not.Null);
Assert.That(entry!.Spellboost, Is.EqualTo(2));
}
[Test]
public void BuildPlayedCard_defaults_spellboost_to_zero_when_idx_unmapped_or_no_map()
{
var deckMap = new Dictionary<int, long> { [3] = 101311010L };
var otherIdx = new Dictionary<int, int> { [9] = 4 };
Assert.That(KnownListBuilder.BuildPlayedCard(deckMap, 3, OrderListMove(3, 10, 20), otherIdx)!.Spellboost, Is.EqualTo(0));
Assert.That(KnownListBuilder.BuildPlayedCard(deckMap, 3, OrderListMove(3, 10, 20))!.Spellboost, Is.EqualTo(0));
}
[Test]
public void MineAlterSpellboosts_yields_op_and_amount_for_every_idx()
{
var mined = KnownListBuilder.MineAlterSpellboosts(AlterSpellboostOp(new[] { 3L, 41L, 42L }, "a1")).ToList();
Assert.That(mined.Select(m => m.Idx), Is.EquivalentTo(new[] { 3, 41, 42 }));
Assert.That(mined.All(m => m.IsSelf == CardOwner.Self && m.Op == 'a' && m.Amount == 1), Is.True);
}
[Test]
public void MineAlterSpellboosts_routes_cross_side_with_isSelf_0_and_parses_set()
{
var mined = KnownListBuilder.MineAlterSpellboosts(AlterSpellboostOp(new[] { 5L }, "s3", isSelf: 0)).Single();
Assert.That(mined.IsSelf, Is.EqualTo(CardOwner.Opponent));
Assert.That(mined.Op, Is.EqualTo('s'));
Assert.That(mined.Amount, Is.EqualTo(3));
}
[Test]
public void MineAlterSpellboosts_skips_alters_without_spellboost_and_null()
{
var costAlter = new List<object?>
{
new Dictionary<string, object?> { ["alter"] = new Dictionary<string, object?>
{ ["idx"] = new List<object?> { 3L }, ["isSelf"] = 1L, ["cost"] = "s1" } },
};
Assert.That(KnownListBuilder.MineAlterSpellboosts(costAlter), Is.Empty);
Assert.That(KnownListBuilder.MineAlterSpellboosts(null), Is.Empty);
}
[Test]
public void RenameTargets_passes_isSelf_through_verbatim()
{