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:
@@ -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)]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
43
SVSim.BattleEngine.Tests/Coverage/CleanupRegressionTests.cs
Normal file
43
SVSim.BattleEngine.Tests/Coverage/CleanupRegressionTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
72
SVSim.BattleEngine.Tests/Coverage/CoverageRecorder.cs
Normal file
72
SVSim.BattleEngine.Tests/Coverage/CoverageRecorder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
42
SVSim.BattleEngine.Tests/Coverage/CoverageRecorderTests.cs
Normal file
42
SVSim.BattleEngine.Tests/Coverage/CoverageRecorderTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
37
SVSim.BattleEngine.Tests/Coverage/CoverageSetUpFixture.cs
Normal file
37
SVSim.BattleEngine.Tests/Coverage/CoverageSetUpFixture.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
54
SVSim.BattleEngine.Tests/SequentialBattleTests.cs
Normal file
54
SVSim.BattleEngine.Tests/SequentialBattleTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user