#nullable enable using System.Linq; using System.Threading.Tasks; using NUnit.Framework; using SVSim.BattleEngine.Ambient; using SVSim.BattleNode.Sessions.Engine; namespace SVSim.BattleEngine.Tests; /// The forcing-function tests for the multi-instancing migration (Task 8). Each engine /// instance carries its OWN 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). [TestFixture, Parallelizable(ParallelScope.All)] public class MultiInstanceEngineTests { [OneTimeSetUp] public void OneTimeSetUp() => HeadlessEngineEnv.EnsureProcessGlobals(); [Test] public async Task TwoBattles_ResolveIndependently_OnDifferentTasks() { var engineA = new SessionBattleEngine(); var engineB = new SessionBattleEngine(); engineA.Setup(masterSeed: 111, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(), seatAClass: 1, seatBClass: 2); engineB.Setup(masterSeed: 222, HeadlessEngineEnv.SampleDeck(), HeadlessEngineEnv.SampleDeck(), seatAClass: 5, seatBClass: 7); var taskA = Task.Run(() => DriveBasicTurns(engineA)); 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. Assert.That(engineA.LeaderLife(true), Is.EqualTo(20)); Assert.That(engineB.LeaderLife(true), Is.EqualTo(20)); Assert.That(engineA.Pp(true), Is.EqualTo(0)); Assert.That(engineB.Pp(true), Is.EqualTo(0)); Assert.That(engineA.HandCount(true), Is.EqualTo(0)); Assert.That(engineB.HandCount(true), Is.EqualTo(0)); } [Test] public async Task StressN_BaselineMatches([Values(4, 8, 16)] int n) { var inputs = new (int seed, long[] deckA, long[] deckB)[n]; 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(); e.Setup(input.seed, input.deckA, input.deckB); DriveBasicTurns(e); return e.LeaderLife(true); }))); var sequential = new int[n]; for (int i = 0; i < n; i++) { var e = new SessionBattleEngine(); e.Setup(inputs[i].seed, inputs[i].deckA, inputs[i].deckB); DriveBasicTurns(e); sequential[i] = e.LeaderLife(true); } Assert.That(parallel, Is.EqualTo(sequential)); } [Test] public void GameMgr_GetIns_WithoutScope_Throws() { Assert.That(BattleAmbient.Current, Is.Null); Assert.Throws(() => GameMgr.GetIns()); } private static void DriveBasicTurns(SessionBattleEngine e) { _ = e.LeaderLife(true); _ = e.Pp(true); _ = e.HandCount(true); } }