Squashes 146 commits from battle-engine-extraction. Net: 2,045 files changed, +11,896 / -158,687 lines. Ships engine passes 4-7 (dead-code cull, view-layer stub, receive-path shrink) plus the Phase-5 AsyncLocal ambient deletion that turns concurrent battles into a type-system property rather than a scope contract. ## What landed **Passes 4-7 (chunks 1-34):** Extended the Phase-4 const-false collapse into a cascading cull across the skill graph, view layer, and receive-path periphery. Six mode flags (IsWatchBattle/IsReplayBattle/IsAdmin/IsAdminWatch/IsPuzzleQuest/ IsAINetwork) became `const false`, every guarded block deleted. Field*.cs subclass ctors + BackGroundBase + ObjectChecker culled to no-ops. Mulligan family reworked to take a mgr param through IMulliganMgr.InitMulligan. Emotion/Recovery/Resource clusters null-stubbed. Prediction/OperationSimulator/ skill filters converted from static ambient reads to per-mgr reads via SkillPrm.ownerCard.SelfBattlePlayer.BattleMgr / ins.BattleMgr / this.BattleMgr. **Phase-5 ambient rip (chunks 35-47):** Deleted BattleAmbient / BattleAmbient- Context / TestBattleScope in full. Every per-battle mutable slot now lives on the mgr instance itself: mgr.InstanceIsForecast / InstanceIsRandomDraw / InstanceRecoveryInfo / InstanceViewerId / InstanceNetworkAgent / GameMgr BattleManagerBase.GetIns() returns null unconditionally; the residual static flags + 3 façades (Certification.ViewerId, Data.BattleRecoveryInfo, ToolboxGame.RealTimeNetworkAgent) are null-tolerant defaults kept for the handful of engine-internal readers that still reference their types. Zero BattleAmbient references anywhere in engine + node + tests. Added pre-seeded GameMgr ctor overload threaded through the mgr chain (BattleManagerBase → SingleBattleMgr / NetworkBattleManagerBase → NetworkStandard- BattleMgr → HeadlessBattleMgr / HeadlessNetworkBattleMgr). Fixtures build a GameMgr, seed it via HeadlessEngineEnv.SeedCharaIds/SeedNetUser, and pass it to the mgr's ctor — no ambient reach. Node side (SVSim.BattleNode/SessionBattleEngine): _ctx replaced with a plain GameMgr field; 34 `using var _ambient = BattleAmbient.Enter(_ctx)` scope wraps ripped from every accessor and mutator; EngineGlobalInit.WirePerSessionGameMgr takes GameMgr as a param and runs from SessionBattleEngine.SetupInternal BEFORE mgr construction. Test side: TestBattleScope deleted; 18 fixture [SetUp]s migrated to `HeadlessEngineEnv.EnsureProcessGlobals()`; MultiInstanceEngineTests rewritten around per-mgr construction (GetIns() → null is the pinned invariant). ## Regression fixes - **chunk-48** (MulliganCtrl): chunk-35's `= null` stubs on card lookups broke the live receive-driven Deal path (BattlePlayerBase.DrawCard NRE'd downstream of NetworkPlayerMulliganCtrl.StartMulliganVfx). Restored the three lookups via `_battlePlayer.BattleMgr.GetBattleCardIdx`. Engine tests were satisfied by the WireMulliganPhase seam; unit tests exposed the live-path gap. ## Ship state - SVSim.BattleEngine.Tests: 56/56 pass, 2 skip - SVSim.UnitTests: 1554/1554 pass (was 1523/31-fail before chunk 48) - Solution build: 0 source warnings (40 pre-existing NU1902 MessagePack CVEs in SVSim.EmulatedEntrypoint, unrelated) - Sequential PVP smoke: verified live (two back-to-back battles, no regression on cleanup/spinup) - Concurrent PVP smoke: verified live Adds tools/engine-port/ClosureAnalyzer/ — the Roslyn transitive-type-closure analyzer needed to make future cascade cleanup safe (per feedback memory "Engine cleanup needs closure tool" from the 2026-06-28 pass-3 failure). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
149 lines
9.1 KiB
C#
149 lines
9.1 KiB
C#
using System.Reflection;
|
|
using NUnit.Framework;
|
|
using Wizard;
|
|
using Wizard.Battle;
|
|
|
|
namespace SVSim.BattleEngine.Tests
|
|
{
|
|
// M11 (the GATE itself is the oracle): every prior milestone either had no skill_condition or
|
|
// seeded its gate TRUE so the effect fires (M4 seeded play_count>2; M10 seeded a play_count
|
|
// VALUE). None proved the engine SUPPRESSES an effect when a skill_condition evaluates FALSE —
|
|
// the dual of "effect fires". M11 proves conditional BRANCHING resolves headless by asserting
|
|
// BOTH directions of the SAME gated card in ONE fixture (design "M11 — NEXT" resume guide):
|
|
//
|
|
// * gate TRUE (play_count > 2, seeded via the public AddCurrentTrunPlayCount seam M4/M10 use)
|
|
// -> the when_play powerup fires -> the follower is buffed over its base stats.
|
|
// * gate FALSE (play_count <= 2, the bare-construction default)
|
|
// -> the powerup is a NO-OP: zero stat delta, BUT the card still pays its cost
|
|
// and still leaves hand -> board (the gate suppresses the EFFECT, not the PLAY).
|
|
//
|
|
// Card: 103111050 — the M4 self-buff follower (ELF clan-1 cost-1 base 1/1, sole non-evo skill
|
|
// `when_play` `powerup` `add_offense=1&add_life=1` to `character=me&target=self`), whose
|
|
// skill_condition is `character=me&target=self&play_count>2` (verified in cards.json). The gate
|
|
// reads BattlePlayerBase.GetCurrentTurnPlayCount(), seedable past/below the threshold via the
|
|
// public AddCurrentTrunPlayCount. Reusing the M4-proven buff DIMENSION means the only NEW thing
|
|
// under test is the CONDITIONAL — exactly the resume-guide's "proven effect dimension, gate is
|
|
// the oracle" prescription.
|
|
//
|
|
// Why one fixture, both branches, ONE card is decisive: the two assertions are jointly
|
|
// satisfiable ONLY by a correctly-gating engine. An "always-buffs" engine fails the FALSE branch
|
|
// (would buff with play_count=0); a "never-buffs" engine fails the TRUE branch (M4's gate seed
|
|
// wouldn't fire). M4 already demonstrated this split as a manual load-bearing probe (remove the
|
|
// seed -> buff vanishes); M11 promotes it to the PRIMARY assertion.
|
|
[TestFixture]
|
|
public class GatedConditionalOracleTests
|
|
{
|
|
// A clearly super-threshold seed (play_count 5 > 2): the gate evaluates TRUE, fanfare fires.
|
|
private const int GateTrueSeed = 5;
|
|
// The bare-construction default is play_count 0 (<= 2 -> gate FALSE); we seed nothing for the
|
|
// FALSE branch, exactly as M4's load-bearing probe did when it removed its seed.
|
|
private const int GateFalseSeed = 0;
|
|
|
|
|
|
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
|
|
|
|
private static void SetPrivateField(object obj, string name, object value)
|
|
{
|
|
var t = obj.GetType();
|
|
var f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
|
|
while (f == null && t.BaseType != null) { t = t.BaseType; f = t.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); }
|
|
Assert.That(f, Is.Not.Null, $"field {name} not found on {obj.GetType().Name}");
|
|
f.SetValue(obj, value);
|
|
}
|
|
|
|
// Resolve the gated self-buff follower on a FRESH battle with the per-turn play count seeded
|
|
// to `seededPlayCount`, and report the play's outcome. A fresh mgr per branch is required:
|
|
// play_count is per-mgr state and a resolved play mutates the board, so the two branches must
|
|
// not share a battle. Mirrors the M4 BuffFollowerOracleTests setup verbatim, parameterized on
|
|
// the seed (which is the only thing M11 varies between branches).
|
|
private (BattleCardBase card, CardParameter param, int ppBefore, int ppAfter,
|
|
int handBefore, bool inHandAfter, int inplayBefore, bool onBoardAfter, int inplayAfter)
|
|
PlayGatedSelfBuff(int seededPlayCount)
|
|
{
|
|
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
|
|
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
|
|
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
|
|
|
|
var player = mgr.BattlePlayer;
|
|
var enemy = mgr.BattleEnemy;
|
|
|
|
// Minimal opponent/turn wiring (M2/M3/M4 oracles): opponent refs + active turn flag. The
|
|
// self-buff target resolver (`character=me&target=self`) reads the active player's own
|
|
// in-play card, so the turn flag must be set before the fanfare sweeps.
|
|
SetPrivateField(player, "_opponentBattlePlayer", enemy);
|
|
SetPrivateField(enemy, "_opponentBattlePlayer", player);
|
|
player.IsSelfTurn = true;
|
|
enemy.IsSelfTurn = false;
|
|
|
|
// Seed leader life so neither leader reads as a 0-life game-over that silently blocks the
|
|
// play (M3 learning). This card deals no damage but the play-legality gate still checks it.
|
|
HeadlessEngineEnv.InitLeaderLife(mgr);
|
|
|
|
// THE GATE SEED — the one knob M11 turns between branches. The skill_condition
|
|
// `play_count>2` reads BattlePlayerBase.GetCurrentTurnPlayCount(); seed it via the public
|
|
// AddCurrentTrunPlayCount (M4/M10 seam). For the FALSE branch we leave the bare default 0.
|
|
if (seededPlayCount > 0) player.AddCurrentTrunPlayCount(seededPlayCount);
|
|
|
|
var cardParam = CardMaster.GetInstanceForBattle().GetCardParameterFromId(HeadlessEngineEnv.BuffFollowerId);
|
|
|
|
// Place the gated self-buff follower in the active player's hand with PP to spare; empty board.
|
|
var card = HeadlessEngineEnv.CreateHeadlessHandCard(HeadlessEngineEnv.BuffFollowerId, 1, isPlayer: true, mgr);
|
|
player.HandCardList.Add(card);
|
|
player.Pp = 10;
|
|
|
|
int ppBefore = player.Pp;
|
|
int handBefore = player.HandCardList.Count;
|
|
int inplayBefore = player.ClassAndInPlayCardList.Count;
|
|
|
|
var pair = mgr.GetBattlePlayerPair(isPlayer: true);
|
|
var ap = new ActionProcessor(pair);
|
|
Assert.DoesNotThrow(() => ap.PlayCard(card, selectedCards: null),
|
|
$"ActionProcessor.PlayCard threw on the gated self-buff (seed={seededPlayCount})");
|
|
|
|
return (card, cardParam, ppBefore, player.Pp,
|
|
handBefore, player.HandCardList.Contains(card),
|
|
inplayBefore, player.ClassAndInPlayCardList.Contains(card),
|
|
player.ClassAndInPlayCardList.Count);
|
|
}
|
|
|
|
[Test]
|
|
public void Gated_fanfare_fires_when_seeded_true_and_is_suppressed_when_false()
|
|
{
|
|
// ----- Branch 1: gate TRUE (play_count 5 > 2) -> the fanfare FIRES (M4 dimension). -----
|
|
var t = PlayGatedSelfBuff(GateTrueSeed);
|
|
|
|
// ----- Branch 2: gate FALSE (play_count 0 <= 2) -> the fanfare is SUPPRESSED. -----
|
|
var f = PlayGatedSelfBuff(GateFalseSeed);
|
|
|
|
Assert.Multiple(() =>
|
|
{
|
|
// PRIMARY M11 assertion — the gate itself: SAME card, opposite stat outcomes driven
|
|
// ONLY by the seeded condition.
|
|
// TRUE -> buffed: base 1/1 + 1/1 = 2/2.
|
|
Assert.That(t.card.Atk, Is.EqualTo(t.param.Atk + HeadlessEngineEnv.BuffAddOffense),
|
|
"[gate TRUE] atk != base + add_offense (fanfare should have fired)");
|
|
Assert.That(t.card.Life, Is.EqualTo(t.param.Life + HeadlessEngineEnv.BuffAddLife),
|
|
"[gate TRUE] life != base + add_life (fanfare should have fired)");
|
|
// FALSE -> unbuffed: stays at the CardCSVData base 1/1 (effect suppressed).
|
|
Assert.That(f.card.Atk, Is.EqualTo(f.param.Atk),
|
|
"[gate FALSE] atk != base (fanfare should have been gated out)");
|
|
Assert.That(f.card.Life, Is.EqualTo(f.param.Life),
|
|
"[gate FALSE] life != base (fanfare should have been gated out)");
|
|
|
|
// The gate suppresses the EFFECT, not the PLAY: in BOTH branches the card still pays
|
|
// its cost and still moves hand -> board like any follower.
|
|
// TRUE branch:
|
|
Assert.That(t.ppAfter, Is.EqualTo(t.ppBefore - t.param.Cost), "[gate TRUE] PP not reduced by cost");
|
|
Assert.That(t.inHandAfter, Is.False, "[gate TRUE] card still in hand");
|
|
Assert.That(t.onBoardAfter, Is.True, "[gate TRUE] card not on board");
|
|
Assert.That(t.inplayAfter, Is.EqualTo(t.inplayBefore + 1), "[gate TRUE] in-play count not +1");
|
|
// FALSE branch — the M11 crux: cost STILL paid + card STILL resolves despite the no-op effect.
|
|
Assert.That(f.ppAfter, Is.EqualTo(f.ppBefore - f.param.Cost), "[gate FALSE] PP not reduced by cost");
|
|
Assert.That(f.inHandAfter, Is.False, "[gate FALSE] card still in hand");
|
|
Assert.That(f.onBoardAfter, Is.True, "[gate FALSE] card not on board");
|
|
Assert.That(f.inplayAfter, Is.EqualTo(f.inplayBefore + 1), "[gate FALSE] in-play count not +1");
|
|
});
|
|
}
|
|
}
|
|
}
|