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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>True once Setup has built the two-seat battle.</summary>
|
||||
public bool IsReady => _mgr is not null;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
public int PlayedCardSpellboost => _lastPlayedSpellboost;
|
||||
|
||||
/// <summary>Construct the two-seat network battle from both decks + the master seed (design F-N-5).
|
||||
/// <paramref name="seatADeck"/>/<paramref name="seatBDeck"/> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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 <see
|
||||
/// cref="PlayedCardSpellboost"/>).</summary>
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user