engine cleanup passes 4-7 + multi-instancing ambient rip

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>
This commit is contained in:
gamer147
2026-07-03 19:18:54 -04:00
parent 5c1db83967
commit 2d9a6eea4b
2045 changed files with 11704 additions and 158495 deletions

View File

@@ -1,7 +1,7 @@
// Each engine-state fixture wraps its tests in a TestBattleScope, so AsyncLocal ambient
// isolates per-test state (mgr/GameMgr/IsForecast/IsRandomDraw/RecoveryInfo/etc.). The
// residual process-globals (Unity Resources shim cache, Wizard.LocalLog accumulators) are
// now thread-safe (ConcurrentDictionary / static lock), so fixtures can run in parallel.
// Per-test isolation: after the Phase-5 ambient rip (chunks 38-47), every piece of per-battle
// mutable state lives on the mgr instance itself; there is no shared ambient to leak across
// fixtures. Residual process-globals (Unity Resources shim cache, Wizard.LocalLog accumulators)
// are already thread-safe (ConcurrentDictionary / static lock), so fixtures run in parallel.
using NUnit.Framework;
[assembly: Parallelizable(ParallelScope.Fixtures)]

View File

@@ -1,246 +0,0 @@
#nullable enable
using SVSim.BattleEngine.Ambient;
using NUnit.Framework;
using System.Runtime.Serialization;
using System.Threading.Tasks;
namespace SVSim.BattleEngine.Tests;
[TestFixture, Parallelizable(ParallelScope.Self)]
public class BattleAmbientTests
{
[Test]
public void Current_IsNull_WhenNoScope()
{
Assert.That(BattleAmbient.Current, Is.Null);
}
[Test]
public void Require_Throws_WhenNoScope()
{
Assert.Throws<System.InvalidOperationException>(() => BattleAmbient.Require());
}
[Test]
public void Enter_SetsCurrent_RestoresOnDispose()
{
var ctx = new BattleAmbientContext { ViewerId = 42 };
Assert.That(BattleAmbient.Current, Is.Null);
using (var _ = BattleAmbient.Enter(ctx))
{
Assert.That(BattleAmbient.Current, Is.SameAs(ctx));
Assert.That(BattleAmbient.Require().ViewerId, Is.EqualTo(42));
}
Assert.That(BattleAmbient.Current, Is.Null);
}
[Test]
public void Enter_Nested_RestoresPriorOnDispose()
{
var outer = new BattleAmbientContext { ViewerId = 1 };
var inner = new BattleAmbientContext { ViewerId = 2 };
using (var _o = BattleAmbient.Enter(outer))
{
Assert.That(BattleAmbient.Current!.ViewerId, Is.EqualTo(1));
using (var _i = BattleAmbient.Enter(inner))
{
Assert.That(BattleAmbient.Current!.ViewerId, Is.EqualTo(2));
}
Assert.That(BattleAmbient.Current!.ViewerId, Is.EqualTo(1));
}
}
[Test]
public async Task Enter_FlowsAcrossAwait()
{
var ctx = new BattleAmbientContext { ViewerId = 99 };
using (var _ = BattleAmbient.Enter(ctx))
{
await Task.Yield();
Assert.That(BattleAmbient.Current, Is.SameAs(ctx));
}
}
[Test]
public async Task Enter_IsolatedBetweenConcurrentTasks()
{
var ctxA = new BattleAmbientContext { ViewerId = 100 };
var ctxB = new BattleAmbientContext { ViewerId = 200 };
var taskA = Task.Run(async () => {
using var _ = BattleAmbient.Enter(ctxA);
await Task.Delay(20);
return BattleAmbient.Current!.ViewerId;
});
var taskB = Task.Run(async () => {
using var _ = BattleAmbient.Enter(ctxB);
await Task.Delay(20);
return BattleAmbient.Current!.ViewerId;
});
var results = await Task.WhenAll(taskA, taskB);
Assert.That(results[0], Is.EqualTo(100));
Assert.That(results[1], Is.EqualTo(200));
}
[Test]
public void IsForecast_ReadsAmbient_WhenScopeActive()
{
var ctx = new BattleAmbientContext { IsForecast = false };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(BattleManagerBase.IsForecast, Is.False);
ctx.IsForecast = true;
Assert.That(BattleManagerBase.IsForecast, Is.True);
}
[Test]
public void IsForecast_WriteInsideScope_WritesAmbient_NotFallback()
{
var ctx = new BattleAmbientContext { IsForecast = false };
using (var _ = BattleAmbient.Enter(ctx))
{
BattleManagerBase.IsForecast = true;
Assert.That(ctx.IsForecast, Is.True);
}
}
[Test]
public void IsForecast_OutsideScope_GetAndSetThrow()
{
// Post-Task-8: fallback is gone. Both get and set go through BattleAmbient.Require(),
// which throws when no scope is active. This is the forcing function — any unwrapped
// engine code that touches IsForecast fails fast instead of silently writing a static.
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => { var _ = BattleManagerBase.IsForecast; });
Assert.Throws<System.InvalidOperationException>(() => BattleManagerBase.IsForecast = true);
}
[Test]
public void IsRandomDraw_OutsideScope_GetAndSetThrow_InsideScope_Roundtrips()
{
// Post-Task-8: get/set both Require() a scope. Inside a scope, writes land on the ctx.
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => { var _ = BattleManagerBase.IsRandomDraw; });
Assert.Throws<System.InvalidOperationException>(() => BattleManagerBase.IsRandomDraw = true);
var ctx = new BattleAmbientContext { IsRandomDraw = false };
using (var _ = BattleAmbient.Enter(ctx))
{
Assert.That(BattleManagerBase.IsRandomDraw, Is.False);
BattleManagerBase.IsRandomDraw = true;
Assert.That(ctx.IsRandomDraw, Is.True);
}
// Scope disposed -> back to throwing on access.
Assert.Throws<System.InvalidOperationException>(() => { var _ = BattleManagerBase.IsRandomDraw; });
}
[Test]
public void GetIns_ReadsAmbient_WhenScopeActive()
{
var fakeMgr = (BattleManagerBase)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(BattleManagerBase));
var ctx = new BattleAmbientContext { Mgr = fakeMgr };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(BattleManagerBase.GetIns(), Is.SameAs(fakeMgr));
}
[Test]
public void GetIns_OutsideScope_ReturnsNull()
{
// Post-Task-8: fallback is gone. GetIns() reads Current?.Mgr (soft, kept null-tolerant so
// engine call sites that pattern `GetIns()?.Foo ?? default` still compose). With no scope
// active, Current is null, so GetIns() returns null.
Assert.That(BattleAmbient.Current, Is.Null);
Assert.That(BattleManagerBase.GetIns(), Is.Null);
}
[Test]
public void ViewerId_ReadsAmbient_WhenScopeActive()
{
var ctx = new BattleAmbientContext { ViewerId = 12345 };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(Cute.Certification.ViewerId, Is.EqualTo(12345));
}
[Test]
public void RealTimeNetworkAgent_ReadsAmbient_WhenScopeActive()
{
var ctx = new BattleAmbientContext();
using var _ = BattleAmbient.Enter(ctx);
Assert.That(Wizard.ToolboxGame.RealTimeNetworkAgent, Is.Null);
var agent = (RealTimeNetworkAgent)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
ctx.NetworkAgent = agent;
Assert.That(Wizard.ToolboxGame.RealTimeNetworkAgent, Is.SameAs(agent));
}
[Test]
public void SetRealTimeNetworkBattle_InsideScope_WritesAmbient()
{
var ctx = new BattleAmbientContext();
using var _ = BattleAmbient.Enter(ctx);
var agent = (RealTimeNetworkAgent)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(RealTimeNetworkAgent));
Wizard.ToolboxGame.SetRealTimeNetworkBattle(agent);
Assert.That(ctx.NetworkAgent, Is.SameAs(agent));
}
[Test]
public void BattleRecoveryInfo_ReadsAmbient_WhenScopeActive()
{
var info = (Wizard.BattleRecoveryInfo)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(Wizard.BattleRecoveryInfo));
var ctx = new BattleAmbientContext { RecoveryInfo = info };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(Wizard.Data.BattleRecoveryInfo, Is.SameAs(info));
}
[Test]
public void BattleRecoveryInfo_SetInsideScope_WritesAmbient()
{
var ctx = new BattleAmbientContext();
using var _ = BattleAmbient.Enter(ctx);
var info = (Wizard.BattleRecoveryInfo)System.Runtime.Serialization
.FormatterServices.GetUninitializedObject(typeof(Wizard.BattleRecoveryInfo));
Wizard.Data.BattleRecoveryInfo = info;
Assert.That(ctx.RecoveryInfo, Is.SameAs(info));
}
[Test]
public void GameMgr_GetIns_InsideScope_ReturnsScopeInstance()
{
var mgr = new GameMgr();
var ctx = new BattleAmbientContext { GameMgr = mgr };
using var _ = BattleAmbient.Enter(ctx);
Assert.That(GameMgr.GetIns(), Is.SameAs(mgr));
}
[Test]
public void GameMgr_GetIns_OutsideScope_Throws()
{
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => GameMgr.GetIns());
}
[Test]
public async Task GameMgr_GetIns_IsolatedBetweenConcurrentTasks()
{
var mgrA = new GameMgr();
var mgrB = new GameMgr();
var taskA = Task.Run(async () => {
using var _ = BattleAmbient.Enter(new BattleAmbientContext { GameMgr = mgrA });
await Task.Delay(20);
return GameMgr.GetIns();
});
var taskB = Task.Run(async () => {
using var _ = BattleAmbient.Enter(new BattleAmbientContext { GameMgr = mgrB });
await Task.Delay(20);
return GameMgr.GetIns();
});
var results = await Task.WhenAll(taskA, taskB);
Assert.That(results[0], Is.SameAs(mgrA));
Assert.That(results[1], Is.SameAs(mgrB));
}
}

View File

@@ -14,10 +14,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class BuffFollowerOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -32,8 +30,7 @@ namespace SVSim.BattleEngine.Tests
public void Self_buff_fanfare_raises_own_atk_and_life()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -14,10 +14,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class ConstructionProbeTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public void SingleBattleMgr_constructs_headless()
@@ -30,7 +28,7 @@ namespace SVSim.BattleEngine.Tests
SingleBattleMgr mgr = null;
try
{
mgr = new SingleBattleMgr(new HeadlessContentsCreator());
mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
}
catch (Exception ex)
{
@@ -42,7 +40,6 @@ namespace SVSim.BattleEngine.Tests
Assert.That(mgr, Is.Not.Null);
Assert.That(mgr.BattlePlayer, Is.Not.Null, "BattlePlayer (self) not created");
Assert.That(mgr.BattleEnemy, Is.Not.Null, "BattleEnemy (opponent) not created");
_scope.Ctx.Mgr = mgr;
}
}
}

View File

@@ -0,0 +1,43 @@
extern alias engine;
using NUnit.Framework;
using System.Reflection;
namespace SVSim.BattleEngine.Tests.Coverage;
/// <summary>Regression backstop for the engine shim cleanup. Asserts every type we
/// deleted in Tasks 9-15 of docs/superpowers/plans/2026-06-27-engine-shim-cleanup.md
/// stays deleted. If a rebase or merge accidentally re-introduces one, this test
/// fails loudly.</summary>
[TestFixture]
public class CleanupRegressionTests
{
private static readonly string[] DeletedFqns = new[]
{
// Task 9 — DEAD-ORPHAN sweep (commit 119bf77)
"Wizard.AIBarrierGlobal",
// Task 10 — *Vfx.cs sweep (commit 3235f47)
"Wizard.Battle.Player.ClassCharacter.SkinEffectVfx",
"Wizard.Battle.Player.Emotion.Debug722006NullVfx",
// Task 12 — *Page.cs sweep (commit b3e2df2)
"Wizard.Bingo.BingoPage",
"Wizard.Lottery.LotteryPage",
"Wizard.Scripts.Network.Data.TaskData.BuildDeckPurchase.BuildDeckPurchasePage",
"Wizard.StorySelectPage",
// Task 13 — *Window.cs sweep (commit 92b6bf6)
"Wizard.CardSleeveDetailWindow",
"Wizard.ClassSkinDetailWindow",
"Wizard.OptionSettingWindow",
"Wizard.Scripts.Network.Data.TaskData.BuildDeckPurchase.BuildDeckDetailWindow",
};
[TestCaseSource(nameof(DeletedFqns))]
public void DeletedType_StaysDeleted(string fqn)
{
var asm = typeof(engine::BattleManagerBase).Assembly;
Assert.That(asm.GetType(fqn), Is.Null,
$"Type {fqn} was deleted in the engine shim cleanup (see plan Tasks 9-15) and must not be re-introduced.");
}
}

View File

@@ -0,0 +1,72 @@
extern alias engine;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Reflection;
using HarmonyLib;
namespace SVSim.BattleEngine.Tests.Coverage;
/// <summary>Harmony-prefix-based method-invocation logger over SVSim.BattleEngine.dll.
/// Idempotent: Install is safe to call from every fixture's [OneTimeSetUp]; only the first
/// call patches.</summary>
public static class CoverageRecorder
{
private static readonly ConcurrentDictionary<string, byte> _hit = new();
private static readonly Harmony _h = new("svsim.engine.coverage");
private static bool _installed;
private static readonly object _gate = new();
public static int HitCount => _hit.Count;
public static void Reset() => _hit.Clear();
public static void Install()
{
lock (_gate)
{
if (_installed) return;
_installed = true;
}
var asm = typeof(engine::BattleManagerBase).Assembly;
var prefix = AccessTools.Method(typeof(CoverageRecorder), nameof(Hit));
var hm = new HarmonyMethod(prefix);
foreach (var t in asm.GetTypes())
{
var ns = t.FullName ?? string.Empty;
// Only engine + shim types — skip framework/3rd-party that happens to be co-located.
if (!ns.StartsWith("SVSim.BattleEngine.") &&
!ns.StartsWith("Wizard.") &&
!ns.StartsWith("Cute.") &&
!ns.StartsWith("Toolbox.") &&
!ns.StartsWith("UnityEngine.")) continue;
if (t.IsGenericTypeDefinition) continue;
var members = t.GetMethods(BindingFlags.Instance | BindingFlags.Static |
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.DeclaredOnly);
foreach (var m in members)
{
if (m.IsAbstract) continue;
if (m.ContainsGenericParameters) continue;
if (m.GetMethodBody() is null) continue;
try { _h.Patch(m, prefix: hm); } catch { /* generic/inlined; skipped */ }
}
}
}
public static void Dump(string path)
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllLines(path, _hit.Keys.OrderBy(k => k));
}
private static void Hit(MethodBase __originalMethod)
{
var t = __originalMethod.DeclaringType;
if (t is null) return;
_hit.TryAdd($"{t.FullName}.{__originalMethod.Name}", 0);
}
}

View File

@@ -0,0 +1,42 @@
extern alias engine;
using System.IO;
using System.Linq;
using NUnit.Framework;
using SVSim.BattleEngine.Tests.Coverage;
namespace SVSim.BattleEngine.Tests.Coverage;
[TestFixture, NonParallelizable]
public class CoverageRecorderTests
{
[SetUp] public void SetUp() => CoverageRecorder.Reset();
[Test]
public void Install_RecordsAtLeastOneHit_AfterCallingAnEngineType()
{
CoverageRecorder.Install();
// Phase-5 chunk 47: was `BattleAmbient.Current` (deleted). Call any engine-type method
// with a body so the Harmony prefix records a hit; Certification.ViewerId's getter is a
// small non-inlined engine method that still ships (façade retained; consumers all read
// per-mgr now).
var _ = engine::Cute.Certification.ViewerId;
Assert.That(CoverageRecorder.HitCount, Is.GreaterThan(0));
}
[Test]
public void Dump_WritesSortedFqns_OneLineEach()
{
CoverageRecorder.Install();
// Phase-5 chunk 47: was `BattleAmbient.Current` (deleted). Call any engine-type method
// with a body so the Harmony prefix records a hit; Certification.ViewerId's getter is a
// small non-inlined engine method that still ships (façade retained; consumers all read
// per-mgr now).
var _ = engine::Cute.Certification.ViewerId;
var path = Path.Combine(Path.GetTempPath(), "coverage-recorder-test.txt");
CoverageRecorder.Dump(path);
var lines = File.ReadAllLines(path).ToList();
Assert.That(lines, Is.Not.Empty);
Assert.That(lines, Is.Ordered);
Assert.That(lines.All(l => l.Contains(".")), Is.True);
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.IO;
using NUnit.Framework;
using SVSim.BattleEngine.Tests.Coverage;
namespace SVSim.BattleEngine.Tests;
/// <summary>Assembly-level NUnit fixture. When SVSIM_COVERAGE=1, installs
/// CoverageRecorder before any test runs and dumps the recorded FQNs after
/// the assembly finishes. Outside SVSIM_COVERAGE=1, this fixture is a no-op
/// — production test runs are unaffected.</summary>
[SetUpFixture]
public class CoverageSetUpFixture
{
public static bool IsEnabled =>
Environment.GetEnvironmentVariable("SVSIM_COVERAGE") == "1";
public static string DumpPath =>
Path.Combine(TestContext.CurrentContext.TestDirectory,
"..", "..", "..", "obj", "coverage",
"SVSim.BattleEngine.Tests.live.txt");
[OneTimeSetUp]
public void OneTimeSetUp()
{
if (!IsEnabled) return;
CoverageRecorder.Install();
}
[OneTimeTearDown]
public void OneTimeTearDown()
{
if (!IsEnabled) return;
CoverageRecorder.Dump(DumpPath);
TestContext.Progress.WriteLine($"[coverage] dumped {CoverageRecorder.HitCount} FQNs to {DumpPath}");
}
}

View File

@@ -0,0 +1,34 @@
using NUnit.Framework;
using SVSim.BattleEngine.Tests;
namespace SVSim.BattleEngine.Tests.Coverage;
[TestFixture]
public class CoverageSetUpFixtureTests
{
[Test]
public void DumpPath_IsObjCoverageBattleEngineLiveTxt()
{
Assert.That(
CoverageSetUpFixture.DumpPath,
Does.EndWith("obj/coverage/SVSim.BattleEngine.Tests.live.txt")
.Or.EndWith(@"obj\coverage\SVSim.BattleEngine.Tests.live.txt"));
}
[Test]
public void IsEnabled_ReflectsEnvVar()
{
var prior = System.Environment.GetEnvironmentVariable("SVSIM_COVERAGE");
try
{
System.Environment.SetEnvironmentVariable("SVSIM_COVERAGE", "1");
Assert.That(CoverageSetUpFixture.IsEnabled, Is.True);
System.Environment.SetEnvironmentVariable("SVSIM_COVERAGE", null);
Assert.That(CoverageSetUpFixture.IsEnabled, Is.False);
}
finally
{
System.Environment.SetEnvironmentVariable("SVSIM_COVERAGE", prior);
}
}
}

View File

@@ -21,10 +21,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class DrawSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -39,8 +37,7 @@ namespace SVSim.BattleEngine.Tests
public void Draw_spell_moves_the_seeded_deck_card_into_hand()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -33,10 +33,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class DynamicValueSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -51,8 +49,7 @@ namespace SVSim.BattleEngine.Tests
public void Dynamic_damage_spell_deals_engine_computed_play_count_value()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -12,10 +12,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class EmitPathReadOracleTests : NetworkEmitFixtureBase
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
// The process-global reset (IsForecast=true + clear injected agent) now lives in the shared
// NetworkEmitFixtureBase.ResetNetworkEmitGlobals [TearDown], inherited here — see that file
@@ -25,7 +23,6 @@ namespace SVSim.BattleEngine.Tests
public void M3_spell_driven_via_OperateMgr_reaches_emit_without_crashing()
{
var (mgr, emitted) = HeadlessEngineEnv.NewNetworkEmitBattle();
_scope.Ctx.Mgr = mgr;
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
@@ -58,7 +55,7 @@ namespace SVSim.BattleEngine.Tests
// the flow reaches stockEmitMessageMgr.StockData(info); read it back. If the stock machinery is
// not drivable headless this milestone, this assertion is DEFERRED to structural validation
// (spec §6) — the OnEmit + no-throw + state checks above are the decisive O1 read on their own.
var agent = Wizard.ToolboxGame.RealTimeNetworkAgent;
var agent = mgr.InstanceNetworkAgent; // Phase-5 chunk 41: was Wizard.ToolboxGame.RealTimeNetworkAgent
var stocked = HeadlessEngineEnv.TryReadStockedEmitData(agent); // returns null if unreachable
if (stocked != null)
Assert.That(stocked, Is.Not.Empty, "the emitted dict should be stocked non-empty");

View File

@@ -14,10 +14,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class FixedDamageSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
// The spell's sole skill is `damage=3` to the enemy leader (cards.json skill_option for 900124030).
private const int ExpectedLeaderDamage = 3;
@@ -35,8 +33,7 @@ namespace SVSim.BattleEngine.Tests
public void Fixed_damage_spell_reduces_opponent_leader_life()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -39,10 +39,8 @@ namespace SVSim.BattleEngine.Tests
// FALSE branch, exactly as M4's load-bearing probe did when it removed its seed.
private const int GateFalseSeed = 0;
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -63,8 +61,7 @@ namespace SVSim.BattleEngine.Tests
PlayGatedSelfBuff(int seededPlayCount)
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr; // route GetIns() to this branch's mgr
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -175,10 +175,11 @@ namespace SVSim.BattleEngine.Tests
private static readonly object _processGlobalsGate = new object();
// Process-globals only: load card master, install master data, seed LoadDetail/Crossover,
// seed Certification.udid. Per-battle/per-test state (IsForecast, chara ids on the DataMgr,
// NetworkUserInfoData) is now seeded inside TestBattleScope's ctor against the per-scope
// GameMgr — calling it here would crash because GameMgr.GetIns() Requires an ambient scope.
// Thread-safe (assembly-level Parallelizable(Fixtures) means many fixtures' [SetUp] race here).
// seed Certification.udid. Per-battle/per-test state (chara ids on the DataMgr,
// NetworkUserInfoData) is seeded on the mgr's own GameMgr via NewSeededSingleBattleMgr /
// NewSeededHeadlessBattleMgr / NewSeededHeadlessNetworkBattleMgr in the fixture entry points
// — no shared state to race here. Thread-safe (assembly-level Parallelizable(Fixtures) means
// many fixtures' [SetUp] race this one function).
public static void EnsureProcessGlobals()
{
if (_done) return;
@@ -233,39 +234,57 @@ namespace SVSim.BattleEngine.Tests
return deck;
}
// Per-ambient seeder: writes the player/enemy chara ids onto the AMBIENT GameMgr's DataMgr.
// Called by TestBattleScope after the scope is entered so GameMgr.GetIns() routes to the
// per-test GameMgr, not whichever one happened to be ambient last.
public static void SeedCharaIdsOnCurrentAmbient()
// Per-GameMgr chara-id seeder. Fixtures construct a GameMgr up-front and pass it to the
// mgr ctor (chunk-45 overload); this stamps the player/enemy leader chara ids on its DataMgr.
// Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation /
// AvatarBattle info (more null statics) the resolution path doesn't need.
public static void SeedCharaIds(GameMgr gm)
{
// Player/enemy leaders (chara ids must map to a ClassCharacterMasterData in Master).
// Set the backing fields directly: the public SetPlayerCharaId() also pulls MyRotation/
// AvatarBattle info (more null statics) which the resolution path doesn't need (the
// TryGet* accessors are null-tolerant).
var dm = GameMgr.GetIns().GetDataMgr();
var dm = gm.GetDataMgr();
SetField(dm, "_playerCharaId", HeadlessMasterData.PlayerCharaId);
SetField(dm, "_enemyCharaId", HeadlessMasterData.EnemyCharaId);
}
// Per-ambient seeder: installs a no-op NetworkUserInfoData on the AMBIENT GameMgr so
// NetworkBattleManagerBase.CreateBackgroundId()'s GetNetworkUserInfoData().GetFieldId() call
// resolves (M13). Field id 1 == ForestField, a valid background.
public static void SeedNetUserOnCurrentAmbient()
// Per-GameMgr net-user seeder. NetworkBattleManagerBase.CreateBackgroundId reads
// gm.GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no bg id; the bare
// ctor path leaves _netUser null (no lazy init). Seed a no-op instance whose _selfInfo carries
// fieldId=1 (== ForestField, valid). Only satisfies the bg lookup — no game-state effect.
public static void SeedNetUser(GameMgr gm)
{
// NetworkBattleManagerBase.CreateBackgroundId() (M13) reads
// GameMgr.GetIns().GetNetworkUserInfoData().GetFieldId() when the RecoveryManager yields no
// bg id (NullRecoveryManager.BackGroundId == -1). In production RealTimeNetworkAgent seeds
// this NetworkUserInfoData at match start; the bare construction path leaves GameMgr's
// _netUser null (no lazy init, unlike the other GodObject getters). Seed a no-op instance
// whose _selfInfo carries just "fieldId" (GetFieldId reads _selfInfo["fieldId"]); field id 1
// == ForestField, a valid background. Nothing here drives game state — it only satisfies the
// network mgr's background lookup, a background lookup the single-battle path
// (`SingleBattleMgr`) never performs.
var netUser = new NetworkUserInfoData();
netUser.SetSelfInfo(
new System.Collections.Generic.Dictionary<string, object> { ["fieldId"] = 1 },
isWatchReplayRecovery: false);
GameMgr.GetIns().SetNetworkUserInfoData(netUser);
gm.SetNetworkUserInfoData(netUser);
}
// Phase-5 chunk 46: canonical seeded SingleBattleMgr factory. Replaces the historical
// `new SingleBattleMgr(new HeadlessContentsCreator())` pattern from every oracle test,
// which relied on the ambient bridge to reach a chara-id/net-user-seeded GameMgr. Now the
// GameMgr is built + seeded here and passed to the mgr's chunk-45 ctor overload directly.
public static SingleBattleMgr NewSeededSingleBattleMgr()
{
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
return new SingleBattleMgr(new HeadlessContentsCreator(), gm);
}
// Same idea for the RNG-injectable HeadlessBattleMgr / HeadlessNetworkBattleMgr twins.
public static HeadlessBattleMgr NewSeededHeadlessBattleMgr(IRandomSource rng = null)
{
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
return new HeadlessBattleMgr(new HeadlessContentsCreator(), rng, gm);
}
public static HeadlessNetworkBattleMgr NewSeededHeadlessNetworkBattleMgr(IRandomSource rng = null)
{
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
return new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng, gm);
}
// Seed each leader's starting life on a freshly-constructed mgr. The engine does this in
@@ -376,8 +395,16 @@ namespace SVSim.BattleEngine.Tests
public static HeadlessBattleMgr NewAuthoritativeBattle(IRandomSource rng)
{
EnsureProcessGlobals(); // sets IsForecast = true among other globals
BattleManagerBase.IsRandomDraw = true; // the second RNG gate (F-RNG-2)
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng);
// Phase-5 chunk 45: build a pre-seeded GameMgr and hand it to the mgr ctor, bypassing
// the ambient bridge. The base ctor's BattlePlayer construction reads chara-id/net-user
// through GameMgr.GetIns() (which routes to mgr.GameMgr once base ctor runs), so the
// GameMgr must be seeded BEFORE the mgr ctor completes.
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), rng, gm);
// Phase-5 chunk 42: write InstanceIsRandomDraw directly (mgr not yet ambient-attached).
mgr.InstanceIsRandomDraw = true; // the second RNG gate (F-RNG-2)
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;
@@ -401,7 +428,11 @@ namespace SVSim.BattleEngine.Tests
NewNetworkEmitBattle(IRandomSource rng = null)
{
EnsureProcessGlobals(); // sets IsForecast = true among other globals
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng);
// Phase-5 chunk 45: build + seed a per-mgr GameMgr up-front (no ambient bridge).
var gm = new GameMgr();
SeedCharaIds(gm);
SeedNetUser(gm);
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator(), rng, gm);
// NOTE: IsRecovery is left FALSE here (unlike the solo NewAuthoritativeBattle). The network
// emit path is gated on !IsRecovery in BOTH places: NetworkStandardBattleMgr.SendPlayCard
// (NetworkStandardBattleMgr.cs:155) and the OnSetCardComplete->SendPlayCard subscription in
@@ -419,7 +450,10 @@ namespace SVSim.BattleEngine.Tests
// play exercises the real view layer — those view touches are satisfied by the no-op view shims
// (InitCardTemplates, the HandView/DetailPanel fills below). M3's damage is literal, immune to
// any play-count bump the OperateMgr path adds vs the direct path.
BattleManagerBase.IsForecast = false;
// Phase-5 chunk 42: mgr isn't yet attached to the ambient at this point (_scope.Ctx.Mgr
// is set by the caller after this fixture returns), so BattleManagerBase.IsForecast=false
// would silently no-op (ambient bridge is gone). Write InstanceIsForecast directly.
mgr.InstanceIsForecast = false;
var player = mgr.BattlePlayer;
var enemy = mgr.BattleEnemy;
SetField(player, "_opponentBattlePlayer", enemy);
@@ -447,28 +481,17 @@ namespace SVSim.BattleEngine.Tests
// public setter runs cleanly headless. Prepared (50) is the real enum member (RealTimeNetworkAgent.cs:35).
agent.SetCurrentMatchingStatus(RealTimeNetworkAgent.MatchingStatus.Prepared);
// EmitMsgPack -> AddActionSequence (RealTimeNetworkAgent.cs:1773, fired for the PlayActions URI)
// does `_gungnir._actionSequenceNum++` and `NetworkLogger.LogInfo(...)`. On the
// GetUninitializedObject agent both are null (the real ctor builds them at :289/:301). Seed an
// uninitialized Gungnir (its ctor news a ConnectionReporter + Ticks — unneeded; AddActionSequence
// only touches the int counter) and the engine's own NetworkNullLogger no-op so the action-seq
// bookkeeping runs without crashing. Neither drives game state.
SetField(agent, "_gungnir",
System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(Gungnir)));
// The engine RTA is a pass-7 stub with no-op method bodies. AddActionSequence /
// EmitMsgPack / OnEmit-plumbing were dropped; the historical Gungnir + _notEmit seeds
// are no longer needed. NetworkLogger stays as a NetworkNullLogger since it's a
// typed property (INetworkLogger<NetworkLog>) that some tests may still inspect.
SetProperty(agent, "NetworkLogger", new NetworkNullLogger());
// Suppress the actual socket transmission. After OnEmit fires (RealTimeNetworkAgent.cs:1270, the
// O1 liveness signal), EmitMsgPack -> EmitMsgUriPack reaches the stockEmitMessageMgr / _manager.Socket
// network I/O (RealTimeNetworkAgent.cs:1444+/1487) — none of which exists headless. The engine's
// OWN _notEmit flag (set in recovery/replay) short-circuits EmitMsgUriPack at :1438 BEFORE any of
// that, so the emit stays genuine (OnEmit already fired through the real send path) while the
// byte-push is skipped. This is the only honest way to terminate the path headless: we are NOT
// faking OnEmit, only declining to open a socket we cannot open.
SetField(agent, "_notEmit", true);
var emitted = new System.Collections.Generic.List<NetworkBattleDefine.NetworkBattleURI>();
agent.OnEmit += uri => emitted.Add(uri);
Wizard.ToolboxGame.SetRealTimeNetworkBattle(agent);
// Phase-5 chunk 40: ambient fallback removed from ToolboxGame.SetRealTimeNetworkBattle;
// fixture has mgr in scope, so seed it directly instead of via the ambient-routed static.
mgr.InstanceNetworkAgent = agent;
return (mgr, emitted);
}

View File

@@ -26,10 +26,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class LethalDamageSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -44,8 +42,7 @@ namespace SVSim.BattleEngine.Tests
public void Lethal_damage_spell_kills_the_selected_follower_and_chips_the_survivor()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -2,18 +2,18 @@
using System.Linq;
using System.Threading.Tasks;
using NUnit.Framework;
using SVSim.BattleEngine.Ambient;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests;
/// <summary>The forcing-function tests for the multi-instancing migration (Task 8). Each engine
/// instance carries its OWN <see cref="BattleAmbientContext"/> internally (SessionBattleEngine
/// constructs a per-session ctx in its field initializer and enters it on every Setup/Receive/
/// read), so two engines on two tasks must resolve independently — no shared "current mgr",
/// "current GameMgr", or "current viewer id" state. The stress test pins
/// parallel-equals-sequential to catch any residual contamination (which would manifest as a
/// life/PP/hand-count mismatch between the parallel and sequential runs).</summary>
/// <summary>Forcing-function tests for the multi-instancing migration. After the chunk-46/47
/// ambient rip, per-battle mutable state (IsForecast/IsRandomDraw/RecoveryInfo/ViewerId/
/// NetworkAgent + GameMgr) lives on the mgr instance itself; there is no ambient at all,
/// no "current mgr / current GameMgr" static gate. Two engines on two tasks resolve
/// independently because their mgrs are different objects, full stop — this fixture pins
/// parallel-equals-sequential to catch any residual contamination through the not-yet-culled
/// static accumulators (Wizard.LocalLog trace SB, Unity Resources cache — both already
/// thread-safe).</summary>
[TestFixture, Parallelizable(ParallelScope.All)]
public class MultiInstanceEngineTests
{
@@ -34,13 +34,9 @@ public class MultiInstanceEngineTests
var taskB = Task.Run(() => DriveBasicTurns(engineB));
await Task.WhenAll(taskA, taskB);
// Pin the engines' post-Setup state to concrete starting values: LeaderLife=20 (InitLeaderLife's
// DefaultLeaderLife, applied by SessionBattleEngine.Setup), Pp=0 (pre-first-turn, no PP refill
// has run), HandCount=0 (Setup builds the deck/leader graph but doesn't deal an opening hand —
// mulligan/draw happens once a turn-start phase runs, which DriveBasicTurns doesn't trigger).
// Both engines must report the SAME starting state regardless of distinct masterSeeds, which is
// the cross-contamination property under test: ambient isolation means neither engine's reads
// can leak into the other's seat lookups.
// Pin the engines' post-Setup state to concrete starting values. Both engines must
// report the SAME starting state regardless of distinct masterSeeds, which is the
// cross-contamination property under test.
Assert.That(engineA.LeaderLife(true), Is.EqualTo(20));
Assert.That(engineB.LeaderLife(true), Is.EqualTo(20));
Assert.That(engineA.Pp(true), Is.EqualTo(0));
@@ -56,13 +52,6 @@ public class MultiInstanceEngineTests
for (int i = 0; i < n; i++)
inputs[i] = (1000 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck());
// Setup AND Drive both parallelize: the residual decomp-origin static accumulators
// (Wizard.LocalLog._lastTraceLogStringBuilder etc.) and the Unity Resources shim
// cache are now thread-safe (static lock / ConcurrentDictionary), so two engines
// constructing in parallel no longer corrupts shared scratch state. The full
// construct-then-read pipeline runs concurrently per task and the result still
// pins to the sequential baseline — that is the cross-contamination property
// under test (ambient isolation + safe shared statics).
var parallel = await Task.WhenAll(inputs.Select(input => Task.Run(() =>
{
var e = new SessionBattleEngine();
@@ -84,10 +73,12 @@ public class MultiInstanceEngineTests
}
[Test]
public void GameMgr_GetIns_WithoutScope_Throws()
public void BattleManagerBase_GetIns_Always_Null()
{
Assert.That(BattleAmbient.Current, Is.Null);
Assert.Throws<System.InvalidOperationException>(() => GameMgr.GetIns());
// Post-chunk-46 ambient rip: GetIns() has no scope-based mechanism; every engine consumer
// was converted to per-mgr instance reads, and the residual static returns null so its
// ?. cascade landing in the 3 façades / IsForecast+IsRandomDraw returns their defaults.
Assert.That(BattleManagerBase.GetIns(), Is.Null);
}
private static void DriveBasicTurns(SessionBattleEngine e)

View File

@@ -3,16 +3,17 @@ namespace SVSim.BattleEngine.Tests
// Shared base for every network-emit test fixture (M13 EmitPathReadOracleTests, the
// construction-probe's OnEmit seam test, and any M14+ network fixture to come).
//
// POST-TASK-8 (multi-instancing migration): now empty. The historical hygiene gap this class
// closed (HeadlessEngineEnv.NewNetworkEmitBattle leaving IsForecast=false + a stray injected
// agent visible to a later solo fixture) was a PROCESS-GLOBAL leak via the now-deleted
// Post-Phase-5a: still empty. The historical hygiene gap this class closed
// (HeadlessEngineEnv.NewNetworkEmitBattle leaving IsForecast=false + a stray injected agent
// visible to a later solo fixture) was a PROCESS-GLOBAL leak via the long-deleted
// BattleManagerBase._isForecastFallback + ToolboxGame._realTimeNetworkAgentFallback statics.
// Both fields are gone: IsForecast/RealTimeNetworkAgent live on the per-test ambient context
// (TestBattleScope's BattleAmbientContext), so scope Dispose drops them. A later fixture's
// new TestBattleScope starts a fresh ctx with IsForecast=true and a null NetworkAgent by
// default — exactly the EnsureInitialized invariant the old TearDown manually restored.
// As of Phase 5a IsForecast/RealTimeNetworkAgent live on the mgr instance (authoritative)
// with the per-test BattleAmbientContext as bridge fallback: scope Dispose drops the ctx and
// the next fixture's new TestBattleScope starts a fresh ctx with IsForecast=true and a null
// NetworkAgent — exactly the EnsureInitialized invariant the old TearDown restored.
//
// Kept as a marker base class so derived fixtures don't churn; can be deleted in Task 9.
// Kept as a marker base class so derived fixtures don't churn; deleteable once every
// fixture stops touching the ambient (Phase 5b + ambient rip).
public abstract class NetworkEmitFixtureBase
{
}

View File

@@ -11,18 +11,15 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class NetworkMgrConstructionProbeTests : NetworkEmitFixtureBase
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public void HeadlessNetworkBattleMgr_constructs_headless()
{
Assert.DoesNotThrow(() =>
{
var mgr = new HeadlessNetworkBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededHeadlessNetworkBattleMgr();
Assert.That(mgr, Is.Not.Null);
});
}
@@ -31,10 +28,9 @@ namespace SVSim.BattleEngine.Tests
public void OnEmit_capture_seam_is_wired_via_injected_agent()
{
var (mgr, emitted) = HeadlessEngineEnv.NewNetworkEmitBattle();
_scope.Ctx.Mgr = mgr;
Assert.That(mgr, Is.Not.Null);
Assert.That(Wizard.ToolboxGame.RealTimeNetworkAgent, Is.Not.Null,
"agent must be injected so NetworkBattleSender's ToolboxGame.RealTimeNetworkAgent.* calls resolve");
Assert.That(mgr.InstanceNetworkAgent, Is.Not.Null,
"agent must be injected so NetworkBattleSender's _battleMgr.InstanceNetworkAgent.* calls resolve");
Assert.That(emitted, Is.Empty, "no emit yet — only the seam is wired");
}
}

View File

@@ -16,20 +16,16 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class RandomDrawOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[TearDown]
public void ResetRandomDrawGate()
{
// NewAuthoritativeBattle sets the process-global BattleManagerBase.IsRandomDraw = true; reset it
// so this fixture doesn't leak that state into later-running fixtures (which expect the default
// false / top-of-deck draw behavior). Prevents order-dependent flakes as more RNG oracles land.
// (Now an ambient write inside the scope; harmless either way.)
// NewAuthoritativeBattle sets BattleManagerBase.IsRandomDraw = true on the mgr; the static
// setter now no-ops without a mgr in scope (Phase-5 chunk 46), so this reset became a
// per-mgr concern rather than a global fixture-hygiene one. Kept for symmetry.
BattleManagerBase.IsRandomDraw = false;
_scope?.Dispose();
_scope = null;
}
// Draw with a single scripted unit; return (drawnCardId, deckCountAfter). The deck is seeded with
@@ -38,7 +34,6 @@ namespace SVSim.BattleEngine.Tests
private (int drawnId, int deckAfter) DrawWith(double unit)
{
var mgr = HeadlessEngineEnv.NewAuthoritativeBattle(new ScriptedRandomSource(new[] { unit }));
_scope.Ctx.Mgr = mgr;
var player = mgr.BattlePlayer;
HeadlessEngineEnv.SeedDeck(mgr, HeadlessEngineEnv.RngDeckCardA, index: 2, isPlayer: true);

View File

@@ -9,10 +9,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class RngSeamTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
// RandomSourceBridge.Range must mirror the engine's exact roll arithmetic:
// BattleManagerBase.StableRandom does `(int)Math.Floor((double)val * unit)`.
@@ -72,8 +70,7 @@ namespace SVSim.BattleEngine.Tests
// StableRandomDouble() -> 0.25
// StableRandomOnlySelf(10) -> scripted self pick 4
var src = new ScriptedRandomSource(new[] { 0.5, 0.25 }, new[] { 4 });
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator(), src);
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededHeadlessBattleMgr(src);
Assert.That(mgr.StableRandom(7), Is.EqualTo(3), "StableRandom did not use the injected source");
Assert.That(mgr.randomResult, Is.EqualTo(0.5), "StableRandom must set randomResult to the rolled unit");
@@ -91,8 +88,7 @@ namespace SVSim.BattleEngine.Tests
{
BattleManagerBase.IsForecast = true;
var mgr = new HeadlessBattleMgr(new HeadlessContentsCreator()); // default SeededRandomSource(12345)
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededHeadlessBattleMgr(); // default SeededRandomSource(12345)
var reference = new System.Random(12345);
for (int i = 0; i < 10; i++)

View File

@@ -13,13 +13,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Lib.Harmony" Version="2.3.3" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SVSim.BattleEngine\SVSim.BattleEngine.csproj" />
<ProjectReference Include="..\SVSim.BattleEngine\SVSim.BattleEngine.csproj">
<!-- Expose as both the default 'global' alias (existing tests) and 'engine'
(CoverageRecorder / future coverage fixtures that need explicit disambiguation). -->
<Aliases>global,engine</Aliases>
</ProjectReference>
<ProjectReference Include="..\SVSim.BattleNode\SVSim.BattleNode.csproj" />
</ItemGroup>

View File

@@ -0,0 +1,54 @@
#nullable enable
using NUnit.Framework;
using SVSim.BattleNode.Sessions.Engine;
namespace SVSim.BattleEngine.Tests;
/// <summary>Confirms that constructing engine B after engine A has finished does not inherit
/// any leftover ambient/static state from engine A. Catches the static-not-cleared family of
/// bugs that the multi-instance Task.Run tests don't exercise (their scopes never overlap
/// AsyncLocal flow). Backstop for engine shim cleanup Task 5.</summary>
[TestFixture, NonParallelizable]
public class SequentialBattleTests
{
[OneTimeSetUp]
public void OneTimeSetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public void Sequential_SecondBattle_StartsCleanAfterFirstCompletes()
{
var engineA = new SessionBattleEngine();
engineA.Setup(masterSeed: 333, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(),
seatAClass: 1, seatBClass: 2);
_ = engineA.LeaderLife(true);
_ = engineA.Pp(true);
_ = engineA.HandCount(true);
// Drop engineA. No explicit Dispose today; we rely on scope-exit semantics in SessionBattleEngine.
// Now construct engine B in the same AppDomain.
var engineB = new SessionBattleEngine();
engineB.Setup(masterSeed: 444, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(),
seatAClass: 3, seatBClass: 4);
Assert.That(engineB.LeaderLife(true), Is.EqualTo(20),
"engineB starting life leaked from engineA — sequential static-state bug.");
Assert.That(engineB.Pp(true), Is.EqualTo(0));
Assert.That(engineB.HandCount(true), Is.EqualTo(0));
// Re-read engineA after engineB exists, to confirm engineB's setup didn't poison engineA's reads.
Assert.That(engineA.LeaderLife(true), Is.EqualTo(20),
"engineA reads changed after engineB.Setup — cross-contamination bug.");
}
[Test]
public void Sequential_ThirdBattle_AfterTwoCompletes_StillClean()
{
for (int i = 0; i < 3; i++)
{
var e = new SessionBattleEngine();
e.Setup(masterSeed: 500 + i, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck());
Assert.That(e.LeaderLife(true), Is.EqualTo(20));
Assert.That(e.Pp(true), Is.EqualTo(0));
}
}
}

View File

@@ -7,10 +7,8 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
[TestFixture]
public class SessionEngineConstructionTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public void SessionBattleEngine_instantiates_and_is_not_ready_before_setup()

View File

@@ -9,10 +9,8 @@ namespace SVSim.BattleEngine.Tests.SessionEngine
[TestFixture]
public class SessionEngineShadowReplayTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
// Frames that are transport/keepalive, not game actions — not ingested.
private static readonly HashSet<string> SkipUris = new()

View File

@@ -8,10 +8,8 @@ namespace SVSim.BattleEngine.Tests.SessionEngine;
[TestFixture]
public class SessionEngineSpellboostTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
[Test]
public void EngineGlobalInit_makes_a_fresh_engine_ready()

View File

@@ -18,10 +18,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class SummonTokenOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -36,8 +34,7 @@ namespace SVSim.BattleEngine.Tests
public void Summon_token_spell_places_a_new_token_on_the_board()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -19,10 +19,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class TargetedDamageSpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -37,8 +35,7 @@ namespace SVSim.BattleEngine.Tests
public void Targeted_damage_spell_hits_only_the_selected_enemy_follower()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -21,10 +21,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class TargetedDestroySpellOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -39,8 +37,7 @@ namespace SVSim.BattleEngine.Tests
public void Targeted_destroy_spell_removes_only_the_selected_enemy_follower()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;

View File

@@ -1,50 +0,0 @@
#nullable enable
using System;
using System.Runtime.Serialization;
using SVSim.BattleEngine.Ambient;
namespace SVSim.BattleEngine.Tests;
/// <summary>Per-test ambient scope. Each test that touches engine statics wraps its body
/// in `using var scope = new TestBattleScope();` (or with an explicit Mgr/ViewerId).
///
/// The constructor enters a fresh <see cref="BattleAmbientContext"/> (carrying a brand-new
/// <see cref="GameMgr"/> so per-test mgr/DataMgr writes never bleed across tests), then
/// runs the per-ambient seeders that <see cref="HeadlessEngineEnv.EnsureProcessGlobals"/>
/// no longer does (chara ids on DataMgr, NetworkUserInfoData). Process-globals
/// (card master, LoadDetail, Crossover, Certification.udid) come from
/// <see cref="HeadlessEngineEnv.EnsureProcessGlobals"/> which runs once per process.
///
/// Public surface (vs. internal) so SVSim.UnitTests can reuse it via the same project
/// reference in Task 7.</summary>
public sealed class TestBattleScope : IDisposable
{
private readonly BattleAmbient.Scope _scope;
public BattleAmbientContext Ctx { get; }
public TestBattleScope(BattleManagerBase? mgr = null, int viewerId = 1001)
{
// Make sure process-globals are seeded before we enter; idempotent + cheap after first call.
HeadlessEngineEnv.EnsureProcessGlobals();
Ctx = new BattleAmbientContext
{
Mgr = mgr,
GameMgr = new GameMgr(),
ViewerId = viewerId,
IsForecast = true,
IsRandomDraw = true,
RecoveryInfo = (Wizard.BattleRecoveryInfo)FormatterServices
.GetUninitializedObject(typeof(Wizard.BattleRecoveryInfo)),
};
_scope = BattleAmbient.Enter(Ctx);
// Per-ambient seeders MUST run AFTER scope entry so GameMgr.GetIns() resolves to this
// scope's GameMgr (not a stray one). EnsureProcessGlobals used to do these writes against
// the global GameMgr; now they're scoped.
HeadlessEngineEnv.SeedCharaIdsOnCurrentAmbient();
HeadlessEngineEnv.SeedNetUserOnCurrentAmbient();
}
public void Dispose() => _scope.Dispose();
}

View File

@@ -12,10 +12,8 @@ namespace SVSim.BattleEngine.Tests
[TestFixture]
public class VanillaFollowerOracleTests
{
private TestBattleScope _scope;
[SetUp] public void SetUpScope() { _scope = new TestBattleScope(); }
[TearDown] public void TearDownScope() { _scope?.Dispose(); _scope = null; }
[SetUp] public void SetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
private static void SetPrivateField(object obj, string name, object value)
{
@@ -32,8 +30,7 @@ namespace SVSim.BattleEngine.Tests
public void Vanilla_follower_resolves_to_correct_state()
{
BattleManagerBase.IsForecast = true; // suppress VFX registration (F1)
var mgr = new SingleBattleMgr(new HeadlessContentsCreator());
_scope.Ctx.Mgr = mgr;
var mgr = HeadlessEngineEnv.NewSeededSingleBattleMgr();
mgr.IsRecovery = true; // collapse wait delays to 0 (F1)
var player = mgr.BattlePlayer;