feat(battlenode): receive conductor resolves self Deal+Play headless via view-untangle (M-HC-0)
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>
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using NUnit.Framework;
|
||||
using SVSim.BattleNode.Protocol;
|
||||
|
||||
namespace SVSim.UnitTests.BattleNode.Integration;
|
||||
|
||||
@@ -10,6 +12,11 @@ namespace SVSim.UnitTests.BattleNode.Integration;
|
||||
///
|
||||
/// 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]
|
||||
@@ -31,4 +38,77 @@ public class HeadlessConductorTests
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,10 @@ internal sealed class NodeNativeBattleHarness : IDisposable
|
||||
public int HandCount(bool playerSeat) => Engine.HandCount(playerSeat);
|
||||
public int BoardCount(bool playerSeat) => Engine.BoardCount(playerSeat);
|
||||
|
||||
/// <summary>The engine Index of seat A's hand card at <paramref name="handPos"/> (the playIdx a
|
||||
/// Play frame would carry to play it).</summary>
|
||||
public int PlayerHandCardIndex(int handPos) => Engine.HandCardIndex(playerSeat: true, handPos);
|
||||
|
||||
/// <summary>Build an envelope for <paramref name="body"/> and ingest it into the engine for the
|
||||
/// given seat (player == seat A). Mirrors <c>BattleNodeFlowTests.MakeEnvelopeWith</c> +
|
||||
/// <c>SessionBattleEngine.Receive</c>.</summary>
|
||||
|
||||
Reference in New Issue
Block a user