Files
SVSimServer/SVSim.BattleEngine.Tests/SessionEngine/SessionEngineSpellboostTests.cs
gamer147 fcd64c8c11 feat(battlenode): engine read surface for played-card spellboost (Phase 2 N2 Task 3 — oracle BLOCKED)
Adds SessionBattleEngine.PlayedCardSpellboost + PeekPlayedCardSpellboost (pre-resolve
read of the acting seat's hand card by Index==playIdx) and a CaptureReplay.InterleavedSends
helper. The non-circular capture oracle (engine-derived spellboost vs prod's independent
emission to cl2: idx2->1, idx14->2) is added but [Ignore]'d: the headless receive path does
not apply the wire's authoritative orderList (Deal/Swap don't seat the mulligan hand, draws
follow the seeded deck top instead of the wire move ops, plays never remove the card, alter
spellboost never accumulates), so the engine cannot yet DERIVE the count. Closing this needs
an Engine/*.cs + VfxMgr-execution logic change (escalation per the N2 playbook), not a
mechanical no-op fill. Read surface, node + engine builds, drift, and the rest of the
SessionEngine suite are green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 16:55:47 -04:00

88 lines
4.8 KiB
C#

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;
[TestFixture]
public class SessionEngineSpellboostTests
{
[Test]
public void EngineGlobalInit_makes_a_fresh_engine_ready()
{
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);
// Belt-and-suspenders (matches the sibling tests): load the decks into the harness master so
// this test never depends on global card-master contents. EnsureInitialized() above still
// proves EngineGlobalInit's own path works.
foreach (var id in deckA.Concat(deckB).Distinct()) HeadlessCardMaster.Load((int)id);
var engine = new SessionBattleEngine();
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<int, int> { [2] = 1, [14] = 2 };
var seen = new Dictionary<int, int>();
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");
}
}