diff --git a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs
index d75895c..9710c6d 100644
--- a/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs
+++ b/SVSim.BattleEngine.Tests/SessionEngine/CaptureReplay.cs
@@ -59,6 +59,19 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
return frames;
}
+ /// Both clients' SENT frames interleaved in capture (ts) order, each tagged with its
+ /// seat: cl1 == seat A == player (true), cl2 == seat B == opponent (false). This is the node's
+ /// both-clients-sends ingest order — the same ts ordering the N1 shadow-replay test uses, here
+ /// extended to merge both sides' sends rather than replaying one client's full receive stream.
+ public static IEnumerable<(MsgEnvelope Env, bool Seat)> InterleavedSends(
+ IReadOnlyList cl1, IReadOnlyList cl2)
+ {
+ return cl1.Where(f => f.Direction == "send").Select(f => (f, Seat: true))
+ .Concat(cl2.Where(f => f.Direction == "send").Select(f => (f, Seat: false)))
+ .OrderBy(x => x.f.Ts)
+ .Select(x => (x.f.Env, x.Seat));
+ }
+
/// The selfDeck idx->cardId order from the Matched frame (the order the node also
/// computed and handed the client). This is the deck the engine seats for that side.
public static IReadOnlyList SelfDeckFrom(IEnumerable frames)
diff --git a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs
index bad88f1..ddc40e8 100644
--- a/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs
+++ b/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs
@@ -1,7 +1,10 @@
using NUnit.Framework;
using SVSim.BattleNode.Sessions.Engine;
+using System.Collections.Generic;
using System.Linq;
using SVSim.BattleEngine.Tests;
+using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Sessions.Dispatch;
namespace SVSim.BattleEngine.Tests.SessionEngine;
@@ -24,4 +27,61 @@ public class SessionEngineSpellboostTests
Assert.DoesNotThrow(() => engine.Setup(masterSeed: 12345, seatADeck: deckA, seatBDeck: deckB));
Assert.That(engine.IsReady, Is.True, "engine must be ready after EngineGlobalInit (carried-risk fix)");
}
+
+ // BLOCKED (Phase 2 N2 Task 3): this is the non-circular oracle for engine-derived spellboost — it
+ // replays cl1's RAW send frames (which build charges via `alter` ops but NEVER carry the count) and
+ // asserts the engine's derived count equals prod's INDEPENDENT emission to cl2:
+ // cl1 playIdx 2 (cardId 100314020) -> spellboost 1
+ // cl1 playIdx 14 (cardId 101314020) -> spellboost 2
+ //
+ // It currently FAILS ({2:0, 14:0}) — NOT because of the read surface (PlayedCardSpellboost /
+ // PeekPlayedCardSpellboost are correct and wired), but because the headless receive path does not
+ // apply the wire's authoritative card resolution at all:
+ // * Deal / Swap / Ready do NOT seat the mulligan hand (hand stays empty through them); the
+ // authoritative hand seating is deferred into VfxMgr InstantVfx delegates + an OnReceiveDeal
+ // view callback that the headless shadow does not drive.
+ // * TurnStart draws the engine's OWN deck-top instead of the wire `move` op's idx (so e.g. the
+ // drawn card idx14 never enters hand).
+ // * PlayActions never removes the played card from hand (the play does not resolve), so the
+ // spell_charge skill never fires and no `alter` spellboost accumulates (all cards stay sb0).
+ // Both seats degrade to "top N of the seeded deck", every wire orderList (move/alter/play) a no-op.
+ //
+ // Closing this requires Engine/*.cs (and VfxMgr-execution) LOGIC changes to make the recovery-mode
+ // receive path consume the live wire orderList — which the N2 playbook classifies as an ESCALATION,
+ // not a mechanical no-op fill. [Ignore] keeps the SessionEngine suite green; remove it (and the
+ // skip-list ts-replay scaffolding can collapse to InterleavedSends) once the receive path applies
+ // wire-authoritative resolution. N1's shadow-replay passes only because it asserts structural
+ // INVARIANTS (life/pp/board/hand bounds), never the actual card identities or spellboost VALUE.
+ [Test]
+ [Ignore("BLOCKED N2 Task 3: headless receive path does not apply wire orderList (Deal/draw/play/alter); needs Engine logic change — see comment.")]
+ public void Engine_derives_played_card_spellboost_matching_prod_emission()
+ {
+ EngineGlobalInit.EnsureInitialized();
+
+ var cl1 = CaptureReplay.Load("battle_test_cl1.ndjson");
+ var cl2 = CaptureReplay.Load("battle_test_cl2.ndjson");
+ var deckA = CaptureReplay.SelfDeckFrom(cl1);
+ var deckB = CaptureReplay.SelfDeckFrom(cl2);
+ foreach (var id in deckA.Concat(deckB).Distinct()) HeadlessCardMaster.Load((int)id);
+
+ var engine = new SessionBattleEngine();
+ engine.Setup(masterSeed: CaptureReplay.SeedFrom(cl1), seatADeck: deckA, seatBDeck: deckB);
+
+ var expected = new Dictionary { [2] = 1, [14] = 2 };
+ var seen = new Dictionary();
+
+ foreach (var (env, seat) in CaptureReplay.InterleavedSends(cl1, cl2))
+ {
+ engine.Receive(env, isPlayerSeat: seat);
+ if (seat && env.Uri == NetworkBattleUri.PlayActions)
+ {
+ int playIdx = (int)KnownListBuilder.AsLong(
+ ((env.Body as RawBody)?.Entries ?? new()).GetValueOrDefault(WireKeys.PlayIdx));
+ if (expected.ContainsKey(playIdx)) seen[playIdx] = engine.PlayedCardSpellboost;
+ }
+ }
+
+ Assert.That(seen, Is.EquivalentTo(expected),
+ "engine-derived spellboost must match prod's independent emission to cl2");
+ }
}
diff --git a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
index 239fe72..e8ff81a 100644
--- a/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
+++ b/SVSim.BattleNode/Sessions/Engine/SessionBattleEngine.cs
@@ -3,6 +3,7 @@ using System.Reflection;
using System.Runtime.Serialization;
using engine::SVSim.BattleEngine.Rng;
using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Sessions.Dispatch;
using NetworkBattleReceiver = engine::NetworkBattleReceiver;
using NetworkBattleDefine = engine::NetworkBattleDefine;
using BattleManagerBase = engine::BattleManagerBase;
@@ -40,10 +41,19 @@ internal sealed class SessionBattleEngine
private HeadlessNetworkBattleMgr? _mgr;
private NetworkBattleReceiver? _receiver;
+ private int _lastPlayedSpellboost;
/// True once Setup has built the two-seat battle.
public bool IsReady => _mgr is not null;
+ /// The spellboost (spell-charge) COUNT of the card the most-recently-ingested PlayActions
+ /// frame played, read from the acting seat's hand BEFORE the frame resolved (the count is fixed as the
+ /// card leaves hand; a play that grants spellboost targets the REST of the hand, not the card just
+ /// played). 0 for a non-play frame, a token/unmapped idx, or a card not in hand. PlayActionsHandler
+ /// reads this right after Receive — the BattleSession _dispatchGate serializes Receive→Handle, so this
+ /// is unambiguously this frame's value.
+ public int PlayedCardSpellboost => _lastPlayedSpellboost;
+
/// Construct the two-seat network battle from both decks + the master seed (design F-N-5).
/// / are the per-side deck orders the node
/// already computed (BattleSessionState.GetShuffledDeck) and handed each client.
@@ -115,6 +125,12 @@ internal sealed class SessionBattleEngine
var dict = ToEngineDict((env.Body as RawBody)?.Entries);
var uri = MapUri(env.Uri);
+ // Peek the played card's accumulated spellboost count BEFORE resolution: the count is fixed as
+ // the card leaves hand, so it must be read while the card is still in hand. 0 for any non-play.
+ _lastPlayedSpellboost = uri == NetworkBattleDefine.NetworkBattleURI.PlayActions
+ ? PeekPlayedCardSpellboost(env, isPlayerSeat)
+ : 0;
+
try
{
// Mirror the engine's own recorded-frame replay (RecoveryDataHandler.cs:283): every
@@ -131,6 +147,21 @@ internal sealed class SessionBattleEngine
}
}
+ /// Read the played card's accumulated spellboost count off the acting seat's hand, matching
+ /// the card by Index == wire playIdx. Returns 0 when the body has no playIdx, or no hand card matches
+ /// (a token/unmapped idx, or a card already gone from hand). Pre-resolve read (see ).
+ private int PeekPlayedCardSpellboost(MsgEnvelope env, bool isPlayerSeat)
+ {
+ if (_mgr is null) return 0;
+ var entries = (env.Body as RawBody)?.Entries;
+ if (entries is null) return 0;
+ int playIdx = (int)KnownListBuilder.AsLong(entries.GetValueOrDefault(WireKeys.PlayIdx));
+ foreach (var card in _mgr.GetBattlePlayer(isPlayerSeat).HandCardList)
+ if (card.Index == playIdx) return card.SpellChargeCount;
+ return 0;
+ }
+
// --- live board-state reads (N1 oracle surface; design F-N-4 board-state reads) ----------------
// Each returns LIVE engine state off the seated player, mirroring the Phase-1 oracle reads
// (VanillaFollowerOracleTests: player.Pp, player.HandCardList.Count, ClassAndInPlayCardList,