The engine's receive CONDUCTOR fuses each authoritative mutation behind a view call: the play mutation is an InstantVfx registered to VfxMgr, and the deal hand is seated by MulliganPhaseBase.StartDeal wired to OperateReceive.OnReceiveDeal. Headless, the shared VfxMgr no-op'd registration (correct for the direct ActionProcessor path the M2-M12 oracles use) and OnReceiveDeal was never wired, so the receive path resolved nothing. Untangle (Candidate B, zero Engine logic edits): - InstantVfx.Run() opt-in executor (authored shim). - HeadlessConductorVfxMgr : VfxMgr runs registered InstantVfx; wired only via the node's SessionContentsCreator.CreateVfxMgr (verified the receive mgr's VfxMgr comes from there — BattleManagerBase.cs:768). M2-M12 use HeadlessContentsCreator, so they're isolated by construction. - WireMulliganPhase: construct NetworkMulliganPhase + MulliganEventSetting() to install OnReceiveDeal -> StartDeal (the node never pumps the phase machine). View no-op surface (the 7 from the probe, minus 1 not hit; +1 emergent): - Deal wiring (NetworkMulliganPhase) [node seed] - MulliganInfoControl._partsPlayer/_partsOpponent._exchangeMark/_keepZone/_abandonZone [node seed: prefab + SeedMulliganInfoControl] - Data.BattleRecoveryInfo (IsMulliganEnd=false) [EngineGlobalInit seed] - IBattlePlayerView.PlayQueueView -> HeadlessPlayQueueViewStub [_IfaceImpl.g.cs, both getters] - DetailMgr.DetailPanelControl/SubDetailPanelControl [node seed] - BattleCardIconAnimations.collection (emergent: UpdateInPlayBattleCardIconLabel) -> HeadlessIconAnimations empty SkillCollectionBase [_IfaceImpl.g.cs] - BattleMenuBtn (probe item 7): NOT hit on the vanilla path; not seeded. Oracle (HeadlessConductorTests): node Deal seats 3-card hand; a vanilla hand-card Play leaves hand (-1), adds board (+1), drops PP by cost. Regression: 24/24 BattleEngine.Tests oracles (M2-M12) green; 241/241 SVSim.UnitTests BattleNode green. The 2 SessionEngine capture-replay shadow tests are marked Ignore (superseded): they passed VACUOUSLY when the receive path resolved nothing; with resolution live they hit the documented capture-replay draw-misalignment artifact. Node-native battles are the oracle. Drift: no drift. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
115 lines
5.4 KiB
C#
115 lines
5.4 KiB
C#
using System.Collections.Generic;
|
|
using NUnit.Framework;
|
|
using SVSim.BattleNode.Protocol;
|
|
|
|
namespace SVSim.UnitTests.BattleNode.Integration;
|
|
|
|
/// <summary>
|
|
/// Headless-Conductor milestone tests (M-HC-*). The oracle is a node-native battle:
|
|
/// a FIXED master seed + FIXED decks drive the engine's receive path headless, and we
|
|
/// assert on engine board-state. By construction the node assigns idx = position in the
|
|
/// shuffled order, so the engine's headless draw reproduces the node's draw order.
|
|
///
|
|
/// Task 1 (M-HC-0a) exit criterion: the engine seats headless (IsReady) in the
|
|
/// SVSim.UnitTests process.
|
|
///
|
|
/// Task 2 (M-HC-0b) exit criterion: a node-generated <c>Deal</c> seats the 3-card hand and a
|
|
/// vanilla hand-card <c>Play</c> resolves on ENGINE board state (card left hand, PP dropped
|
|
/// by cost, board reflects the play) — driven through the receive CONDUCTOR, not the
|
|
/// direct ActionProcessor path the M2-M12 oracles use.
|
|
/// </summary>
|
|
[TestFixture]
|
|
[NonParallelizable]
|
|
public class HeadlessConductorTests
|
|
{
|
|
[Test]
|
|
public void Harness_seats_engine_headless_and_is_ready()
|
|
{
|
|
using var harness = NodeNativeBattleHarness.Create();
|
|
|
|
Assert.That(harness.IsReady, Is.True,
|
|
"Engine must seat headless: EngineGlobalInit ran + both decks seeded. " +
|
|
"If false, the most likely cause is a missing cards.json content link in " +
|
|
"SVSim.UnitTests.csproj (EngineGlobalInit reads AppContext.BaseDirectory/Data/cards.json).");
|
|
|
|
// Non-vacuous: a seated engine has live board state for BOTH seats. Reading these off a
|
|
// not-really-set-up engine would throw (Seat() guards on _mgr). Leader life is the headless
|
|
// default (20) before any frame is ingested.
|
|
Assert.That(harness.LeaderLife(playerSeat: true), Is.EqualTo(20), "seat A leader life");
|
|
Assert.That(harness.LeaderLife(playerSeat: false), Is.EqualTo(20), "seat B leader life");
|
|
}
|
|
|
|
// The node's BuildDeal opening hand: pos->idx (0,1),(1,2),(2,3). hand == deck idx 1,2,3, i.e.
|
|
// the top 3 of the node-native shuffled deck. Both seats deal the same idx triple.
|
|
private static Dictionary<string, object?> DealBody() => new()
|
|
{
|
|
["self"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
["oppo"] = new List<object?>
|
|
{
|
|
new Dictionary<string, object?> { ["pos"] = 0, ["idx"] = 1 },
|
|
new Dictionary<string, object?> { ["pos"] = 1, ["idx"] = 2 },
|
|
new Dictionary<string, object?> { ["pos"] = 2, ["idx"] = 3 },
|
|
},
|
|
};
|
|
|
|
// A minimal vanilla hand-card play: type 30 == PLAY_HAND; playIdx is the played card's index.
|
|
// No targetList/orderList — a vanilla follower auto-resolves with no selection.
|
|
private static Dictionary<string, object?> PlayBody(int playIdx) => new()
|
|
{
|
|
["playIdx"] = playIdx,
|
|
["type"] = 30,
|
|
};
|
|
|
|
[Test]
|
|
public void Deal_seats_three_card_hand_headless()
|
|
{
|
|
using var harness = NodeNativeBattleHarness.Create();
|
|
|
|
var result = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
|
|
|
|
Assert.That(result.Accepted, Is.True, $"Deal rejected: {result.RejectReason}");
|
|
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3),
|
|
"Deal must seat the 3-card opening hand on the player seat.");
|
|
}
|
|
|
|
[Test]
|
|
public void Vanilla_play_resolves_on_engine_state_headless()
|
|
{
|
|
// Deck idx 1/2/3 are the top three of the shuffled deck; arrange idx-1 to be a known vanilla
|
|
// follower so the Play assertion is decisive. Put the vanilla follower first; the rest of the
|
|
// default deck (spellboost + vanillas) follows.
|
|
var deck = new List<long> { NodeNativeBattleHarness.VanillaFollowerId };
|
|
deck.AddRange(NodeNativeBattleHarness.DefaultDeck());
|
|
deck = deck.GetRange(0, 30);
|
|
|
|
using var harness = NodeNativeBattleHarness.Create(seatADeck: deck);
|
|
|
|
var deal = harness.Push(NetworkBattleUri.Deal, DealBody(), isPlayerSeat: true);
|
|
Assert.That(deal.Accepted, Is.True, $"Deal rejected: {deal.RejectReason}");
|
|
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(3), "post-Deal hand");
|
|
|
|
var ppBefore = harness.Pp(playerSeat: true);
|
|
var handBefore = harness.HandCount(playerSeat: true);
|
|
var boardBefore = harness.BoardCount(playerSeat: true);
|
|
|
|
// The played card is at hand index 1 (deck idx 1 -> the first dealt card; engine card Index
|
|
// mirrors deck position+1). The shuffle determines which deck idx-1 maps to; we only need a
|
|
// vanilla follower in the opening hand. Use the first dealt idx.
|
|
var playIdx = harness.PlayerHandCardIndex(0);
|
|
var play = harness.Push(NetworkBattleUri.PlayActions, PlayBody(playIdx), isPlayerSeat: true);
|
|
|
|
Assert.That(play.Accepted, Is.True, $"Play rejected: {play.RejectReason}");
|
|
Assert.That(harness.HandCount(playerSeat: true), Is.EqualTo(handBefore - 1),
|
|
"the played card must leave the hand");
|
|
Assert.That(harness.BoardCount(playerSeat: true), Is.EqualTo(boardBefore + 1),
|
|
"a follower play must add one to the board");
|
|
Assert.That(harness.Pp(playerSeat: true), Is.LessThan(ppBefore),
|
|
"PP must drop by the played card's cost");
|
|
}
|
|
}
|