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>
This commit is contained in:
@@ -59,6 +59,19 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
|
||||
return frames;
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static IEnumerable<(MsgEnvelope Env, bool Seat)> InterleavedSends(
|
||||
IReadOnlyList<CapturedFrame> cl1, IReadOnlyList<CapturedFrame> 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));
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public static IReadOnlyList<long> SelfDeckFrom(IEnumerable<CapturedFrame> frames)
|
||||
|
||||
@@ -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<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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user