diff --git a/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs
new file mode 100644
index 0000000..12794fb
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Integration/HeadlessConductorTests.cs
@@ -0,0 +1,33 @@
+using NUnit.Framework;
+
+namespace SVSim.UnitTests.BattleNode.Integration;
+
+///
+/// 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.
+///
+[TestFixture]
+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");
+ }
+}
diff --git a/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs
new file mode 100644
index 0000000..d743385
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Integration/NodeNativeBattleHarness.cs
@@ -0,0 +1,160 @@
+using SVSim.BattleNode.Bridge;
+using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Sessions;
+using SVSim.BattleNode.Sessions.Dispatch;
+using SVSim.BattleNode.Sessions.Engine;
+
+namespace SVSim.UnitTests.BattleNode.Integration;
+
+///
+/// Node-native battle harness for the Headless-Conductor milestones (M-HC-*). It reproduces what
+/// BattleSession.EnsureEngineSetup does — shuffle each side's deck from a FIXED master seed and
+/// SessionBattleEngine.Setup the two seats — then exposes the engine + state + participants so
+/// later milestone tests can drive multi-frame sequences and assert on engine board state.
+///
+/// WHY drive the engine directly (not a full BattleSession): the session's _state
+/// and _engine are private with no fixed-seed injection point, and every milestone assertion is
+/// on engine board state. The engine (SessionBattleEngine) is the unit under test, so we seat it
+/// the same way the session does and skip the WS/dispatch scaffolding.
+///
+/// The oracle by construction: the node assigns idx = position in the shuffled order
+/// (), and the engine's headless draw is lowest-Index
+/// first, so a FIXED seed makes the engine's draw order reproduce the node's BY CONSTRUCTION.
+///
+/// Engine globals (CardMaster, GameMgr, Wizard.Data) are primed by
+/// SessionBattleEngine.Setup itself (it calls EngineGlobalInit.EnsureInitialized(), which
+/// loads the full cards.json from AppContext.BaseDirectory/Data/cards.json). The harness adds no
+/// global init of its own. NOTE: unlike the live session, the harness does NOT acquire
+/// EngineSessionGate — driving the engine directly bypasses it. One engine-backed battle at a
+/// time is assumed within a test (the engine's process-global statics can't back two concurrently).
+///
+internal sealed class NodeNativeBattleHarness : IDisposable
+{
+ /// A deterministic master seed so deck shuffles (and the engine RNG stream born from it)
+ /// are reproducible. Matches the value the engine construction tests use.
+ public const int FixedMasterSeed = 12345;
+
+ /// Default seat A viewer id — distinct from so the two
+ /// sides shuffle independently (the shuffle seed mixes in the viewer id).
+ public const long DefaultSeatAViewerId = 1001;
+ public const long DefaultSeatBViewerId = 1002;
+
+ /// Spellboost cost-reducer card (looking ahead to M-HC-3). Present in cards.json.
+ public const long SpellboostCardId = 101314020;
+
+ /// A second spellboost card seen in the tk2 capture; present in cards.json.
+ public const long SpellboostCardIdAlt = 100314020;
+
+ /// A plain vanilla follower the engine resolution path proved out
+ /// (HeadlessFixture.FollowerId). The bulk of the deterministic deck.
+ public const long VanillaFollowerId = 100011010;
+
+ public BattleSessionState State { get; }
+ public StubParticipant SeatA { get; }
+ public StubParticipant SeatB { get; }
+ public SessionBattleEngine Engine { get; }
+
+ /// This side's deck in the node's shuffled order (idx == position + 1).
+ public IReadOnlyList SeatADeck { get; }
+ public IReadOnlyList SeatBDeck { get; }
+
+ private NodeNativeBattleHarness(
+ BattleSessionState state, StubParticipant a, StubParticipant b, SessionBattleEngine engine,
+ IReadOnlyList seatADeck, IReadOnlyList seatBDeck)
+ {
+ State = state;
+ SeatA = a;
+ SeatB = b;
+ Engine = engine;
+ SeatADeck = seatADeck;
+ SeatBDeck = seatBDeck;
+ }
+
+ /// Build a 30-card deck: mostly the vanilla follower plus a couple of spellboost cards
+ /// (so later milestones have a cost-reducer to play). All ids exist in cards.json.
+ public static IReadOnlyList DefaultDeck()
+ {
+ var deck = new List(30) { SpellboostCardId, SpellboostCardIdAlt };
+ deck.AddRange(Enumerable.Repeat(VanillaFollowerId, 30 - deck.Count));
+ return deck;
+ }
+
+ /// Seat the engine exactly as BattleSession.EnsureEngineSetup does: shuffle each
+ /// side's deck from the fixed seed via , then
+ /// SessionBattleEngine.Setup(seed, deckA, deckB, classA, classB).
+ public static NodeNativeBattleHarness Create(
+ IReadOnlyList? seatADeck = null,
+ IReadOnlyList? seatBDeck = null,
+ CardClass seatAClass = CardClass.Forestcraft,
+ CardClass seatBClass = CardClass.Swordcraft,
+ int masterSeed = FixedMasterSeed)
+ {
+ var state = new BattleSessionState(masterSeed);
+
+ var a = new StubParticipant(DefaultSeatAViewerId, MakeCtx(seatADeck ?? DefaultDeck(), seatAClass));
+ var b = new StubParticipant(DefaultSeatBViewerId, MakeCtx(seatBDeck ?? DefaultDeck(), seatBClass));
+
+ var shuffledA = state.GetShuffledDeck(a);
+ var shuffledB = state.GetShuffledDeck(b);
+
+ var engine = new SessionBattleEngine();
+ engine.Setup(state.MasterSeed, shuffledA, shuffledB,
+ (int)a.Context.ClassId, (int)b.Context.ClassId);
+
+ return new NodeNativeBattleHarness(state, a, b, engine, shuffledA, shuffledB);
+ }
+
+ private static MatchContext MakeCtx(IReadOnlyList deck, CardClass cls) => new(
+ SelfDeckCardIds: deck,
+ ClassId: cls, CharaId: ((int)cls).ToString(), CardMasterName: "card_master_node_10015",
+ CountryCode: CountryCodes.Korea, UserName: "Player", SleeveId: "3000011",
+ EmblemId: "701441011", DegreeId: "300003", FieldId: 43, IsOfficial: 0,
+ BattleModeId: BattleModes.TakeTwo);
+
+ // --- engine board-state pass-throughs (seat:true == player A, false == opponent B) ----------
+
+ public bool IsReady => Engine.IsReady;
+ public int LeaderLife(bool playerSeat) => Engine.LeaderLife(playerSeat);
+ public int Pp(bool playerSeat) => Engine.Pp(playerSeat);
+ public int HandCount(bool playerSeat) => Engine.HandCount(playerSeat);
+ public int BoardCount(bool playerSeat) => Engine.BoardCount(playerSeat);
+
+ /// Build an envelope for and ingest it into the engine for the
+ /// given seat (player == seat A). Mirrors BattleNodeFlowTests.MakeEnvelopeWith +
+ /// SessionBattleEngine.Receive.
+ public EngineIngestResult Push(NetworkBattleUri uri, Dictionary body, bool isPlayerSeat)
+ {
+ var seat = isPlayerSeat ? SeatA : SeatB;
+ var env = new MsgEnvelope(
+ uri, ViewerId: seat.ViewerId, Uuid: "udid-test", Bid: null, RetryAttempt: 0,
+ Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
+ Body: new RawBody(body));
+ return Engine.Receive(env, isPlayerSeat);
+ }
+
+ public void Dispose() { /* engine holds no unmanaged resources; nothing to release. */ }
+
+ /// Minimal test-only exposing only the
+ /// + that the harness reads. All broker members are
+ /// no-ops — the harness drives the engine directly, never the session relay.
+ internal sealed class StubParticipant : IBattleParticipant
+ {
+ public long ViewerId { get; }
+ public MatchContext Context { get; }
+
+ public StubParticipant(long viewerId, MatchContext context)
+ {
+ ViewerId = viewerId;
+ Context = context;
+ }
+
+#pragma warning disable CS0067 // FrameEmitted is part of the interface but the stub never raises it.
+ public event Func? FrameEmitted;
+#pragma warning restore CS0067
+
+ public Task PushAsync(MsgEnvelope envelope, Stock stock, CancellationToken ct) => Task.CompletedTask;
+ public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
+ public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}