From 0d7136787a5680f6adba594fec003ad305eda85c Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sat, 6 Jun 2026 21:48:50 -0400 Subject: [PATCH] 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 --- .../Bodies/PlayActionsBroadcastBody.cs | 9 +- .../Sessions/Dispatch/BattleSessionState.cs | 42 ----- .../Dispatch/Handlers/PlayActionsHandler.cs | 15 +- .../Sessions/Dispatch/KnownListBuilder.cs | 53 ++---- .../Sessions/Engine/SessionBattleEngine.cs | 42 +++++ .../Integration/HeadlessConductorTests.cs | 170 ++++++++++++++++++ .../Integration/NodeNativeBattleHarness.cs | 7 + .../Sessions/BattleSessionStateTests.cs | 43 ----- .../Sessions/KnownListBuilderTests.cs | 74 ++------ 9 files changed, 261 insertions(+), 194 deletions(-) diff --git a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs index 0aef775..a1ada3e 100644 --- a/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs +++ b/SVSim.BattleNode/Protocol/Bodies/PlayActionsBroadcastBody.cs @@ -39,9 +39,12 @@ public sealed record SelectCardEntry( /// One revealed card in a knownList. cardId from the sender's deck map; cost /// 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). spellboost 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). +/// cost 45/45 in captures, so it is NOT omitted). spellboost is now ALSO engine-sourced (M-HC-3b) — +/// the played card's accumulated spell-charge count read straight off the resolved engine +/// (SessionBattleEngine.PlayedCardSpellboost); 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). public sealed record KnownCardEntry( [property: JsonPropertyName("idx")] int Idx, [property: JsonPropertyName("cardId")] long CardId, diff --git a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs index c8a7d42..30b100d 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/BattleSessionState.cs @@ -104,48 +104,6 @@ internal sealed class BattleSessionState RecordToken(isSelf == CardOwner.Self ? from : other, idx, cardId); } - /// Per-side idx->spellboost COUNT, accumulated from orderList alter ops via - /// . Separate from because spellboost is a - /// mutable counter, not an identity. Surfaced by BuildPlayedCard as the played card's - /// knownList.spellboost so the opponent computes its discounted cost (see that method). - public Dictionary> IdxToSpellboost { get; } = new(); - - private Dictionary SpellboostMap(IBattleParticipant side) - { - if (!IdxToSpellboost.TryGetValue(side, out var map)) - IdxToSpellboost[side] = map = new Dictionary(); - return map; - } - - /// The side's idx->spellboost map (empty if nothing recorded yet). Read by - /// PlayActionsHandler to feed BuildPlayedCard. - public IReadOnlyDictionary GetSpellboostMap(IBattleParticipant side) => SpellboostMap(side); - - /// Apply a frame's spellboost alter ops to the per-side maps. Routed by isSelf - /// (the sender's perspective) exactly like : isSelf:1 → the - /// sender's own hand (); isSelf:0 → the opponent's hand - /// () for the rare cross-side spellboost. Ops: 'a' add, 's' set, - /// 'h' half. Call this AFTER BuildPlayedCard 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. - 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) - }; - } - } - /// Mine + record copy/clone-token identities () /// into the correct side's map. A copy's source lives at baseIdx in the actor's own index /// space, so the resolution side == the record side, both selected by the same isSelf routing diff --git a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs index 289dc8d..af5a146 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/Handlers/PlayActionsHandler.cs @@ -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, diff --git a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs index 86c91c4..e0e1a31 100644 --- a/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs +++ b/SVSim.BattleNode/Sessions/Dispatch/KnownListBuilder.cs @@ -10,58 +10,27 @@ namespace SVSim.BattleNode.Sessions.Dispatch; internal static class KnownListBuilder { /// 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). supplies - /// the played card's spellboost COUNT (accumulated from prior alter ops via - /// / BattleSessionState.RecordSpellboostFrom); absent/unmapped - /// idx → 0. Prod sends the real count here and the client reads it straight into the card's cost model - /// (NetworkBattleReceiver spellboost case), so a wrong value makes the opponent compute the - /// card at full price and silently reject the play in OperateReceiveChecker.IsPlayCard - /// (PP-over → ConductError → NullOperationCollection → no render/echo). 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). + /// (token idx not in the deck map, or no matching move op). and + /// are both ENGINE-SOURCED (M-HC-3a/3b) — the handler reads the played + /// card's resolved play-time cost (SessionBattleEngine.PlayedCardCost) and accumulated + /// spell-charge count (SessionBattleEngine.PlayedCardSpellboost) 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 (NetworkBattleReceiver), so a vanilla play + /// resolves to its base cost and count 0. attachTarget stays ""; clan/tribe remain deferred (receiver + /// re-derives from cardId). public static KnownCardEntry? BuildPlayedCard( IReadOnlyDictionary deckMap, int playIdx, object? orderList, - IReadOnlyDictionary? 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); } - /// Mine spellboost-count changes from a sender's orderList alter ops. For each - /// {alter:{idx:[...], isSelf, spellboost:"<op><n>"}} op, yields - /// (idx, isSelf, op, amount) for every idx — op ∈ {'a' add, 's' set, - /// 'h' half} (mirrors RegisterAlter.ChangeType; the leading letter on the value encodes - /// the operation, the rest is the integer amount). isSelf is the sender's perspective tag, - /// surfaced verbatim so the caller routes into the correct side's map (same rule as - /// ). Skips alter ops with no spellboost key (an alter can also carry - /// cost/atk/etc.), a non-string or too-short value, an unparseable amount, or a non-list idx - /// (e.g. a private-group string idx). The only form seen in real captures is "a1" (each spell - /// play adds 1 to the listed hand cards); set/half are handled for completeness. - public static IEnumerable<(int Idx, CardOwner IsSelf, char Op, int Amount)> MineAlterSpellboosts(object? orderList) - { - if (orderList is not IEnumerable ops) yield break; - foreach (var op in ops) - { - if (op is not IDictionary opDict) continue; - if (!opDict.TryGetValue(WireKeys.Alter, out var alterRaw) || alterRaw is not IDictionary 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 idxList) continue; - foreach (var i in idxList) - yield return ((int)AsLong(i), isSelf, opChar, amount); - } - } - /// The to place-state of the FIRST move op whose idx list contains /// (the played card's own move; later add/alter ops are the deferred /// token slice), or null if absent. NOTE: the sender-side to is passed through verbatim — diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs index 28510da..37953ce 100644 --- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs +++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs @@ -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. public int HandCardIndex(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].Index; + /// The real CardId (wire identity) of the hand card at . Lets a + /// test locate a specific card in a SHUFFLED opening hand by identity (then read its + /// to drive a play), without depending on which shuffled position the card landed at. + public int HandCardId(bool playerSeat, int handPos) => Seat(playerSeat).HandCardList[handPos].CardId; + /// The real CardId (wire identity) of the in-play follower at /// (0-based, skipping the leader/Class card at ClassAndInPlayCardList[0] — same convention as /// ). 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; } + /// The engine-RESOLVED spellboost (spell-charge) COUNT of the card whose engine Index == + /// on (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 + /// Skill_spell_charge.AddSpellChargeCount), 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 ). + /// READ-MOMENT (persist-post-play): is set to 0 only + /// in the ctor (re-init, BattleCardBase.cs:2042) and in ReturnCard (bounce-to-hand, + /// BattleCardBase.cs:2681); 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 has. We therefore use the SAME + /// post-resolution zone search (: in-play → cemetery → hand) and read + /// SpellChargeCount directly — no separate receive-capture is needed. + /// Degrades to 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. + 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(); + log.EnemyFusionCard ??= new List(); + } + // 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 diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs index 78f9bb9..c469c34 100644 --- a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs +++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs @@ -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 ReducerAndGrantorDeck() + { + var deck = new List(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"); + } } diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs index 0900105..18ee326 100644 --- a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs +++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs @@ -139,6 +139,13 @@ internal sealed class NodeNativeBattleHarness : IDisposable /// Play frame would carry to play it). public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos); + /// The wire CardId of the hand card at on the given seat. Lets a + /// test find a specific card (e.g. the spellboost reducer) in a shuffled opening hand by identity. + public int HandCardId(bool playerSeat, int handPos) => Engine.HandCardId(playerSeat, handPos); + + /// The engine Index of the hand card at on the given seat. + public int HandCardIndex(bool playerSeat, int handPos) => Engine.HandCardIndex(playerSeat, handPos); + /// The real wire CardId of the in-play follower at on the /// given seat (0-based, leader excluded). Asserts an opponent reveal seated the substituted identity /// (M-HC-2). diff --git a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs index ea7c463..1958ca9 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/BattleSessionStateTests.cs @@ -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 { new Dictionary - { ["alter"] = new Dictionary - { ["idx"] = new List { 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 { new Dictionary - { ["alter"] = new Dictionary - { ["idx"] = new List { 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 { new Dictionary - { ["move"] = new Dictionary - { ["idx"] = new List { 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(); } diff --git a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs index 6b08852..3a86543 100644 --- a/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs +++ b/SVSim.UnitTests/BattleNode/Sessions/KnownListBuilderTests.cs @@ -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 { [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 { [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 AlterSpellboostOp(long[] idxs, string value, long isSelf = 1) => new() - { - new Dictionary - { - ["alter"] = new Dictionary - { - ["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 { [3] = 101311010L }; - var spellboost = new Dictionary { [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 { [3] = 101311010L }; - var otherIdx = new Dictionary { [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 - { - new Dictionary { ["alter"] = new Dictionary - { ["idx"] = new List { 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() {