Step 8 (final) of multi-instancing migration. All per-battle statics now require a BattleAmbient scope — unwrapped writes throw InvalidOperationException (fail-fast forcing function). MultiInstanceEngineTests proves correctness: two parallel battles resolve independently, N=4/8/16 stress matches sequential baseline, GameMgr.GetIns throws without scope. Migration complete. EngineSessionGate gone. Suite fully green. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
101 lines
4.1 KiB
C#
101 lines
4.1 KiB
C#
#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;
|
|
|
|
/// <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>
|
|
[TestFixture, Parallelizable(ParallelScope.Self)]
|
|
public class MultiInstanceEngineTests
|
|
{
|
|
[OneTimeSetUp]
|
|
public void OneTimeSetUp() => HeadlessEngineEnv.EnsureProcessGlobals();
|
|
|
|
[Test]
|
|
public async Task TwoBattles_ResolveIndependently_OnDifferentTasks()
|
|
{
|
|
var deckA1 = HeadlessEngineEnv.SampleDeck(seed: 1);
|
|
var deckA2 = HeadlessEngineEnv.SampleDeck(seed: 2);
|
|
var deckB1 = HeadlessEngineEnv.SampleDeck(seed: 3);
|
|
var deckB2 = HeadlessEngineEnv.SampleDeck(seed: 4);
|
|
|
|
var engineA = new SessionBattleEngine();
|
|
var engineB = new SessionBattleEngine();
|
|
engineA.Setup(masterSeed: 111, deckA1, deckA2, seatAClass: 1, seatBClass: 2);
|
|
engineB.Setup(masterSeed: 222, deckB1, deckB2, seatAClass: 5, seatBClass: 7);
|
|
|
|
var taskA = Task.Run(() => DriveBasicTurns(engineA));
|
|
var taskB = Task.Run(() => DriveBasicTurns(engineB));
|
|
await Task.WhenAll(taskA, taskB);
|
|
|
|
Assert.That(engineA.LeaderLife(true), Is.EqualTo(20));
|
|
Assert.That(engineB.LeaderLife(true), Is.EqualTo(20));
|
|
Assert.That(engineA.Pp(true), Is.GreaterThanOrEqualTo(0));
|
|
Assert.That(engineB.Pp(true), Is.GreaterThanOrEqualTo(0));
|
|
}
|
|
|
|
[Test]
|
|
public async Task StressN_RandomDecks_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(seed: 100 + i * 2),
|
|
HeadlessEngineEnv.SampleDeck(seed: 101 + i * 2));
|
|
|
|
// Setup is process-globally serialized: a small set of decomp-origin static accumulators
|
|
// (Wizard.LocalLog._lastTraceLogStringBuilder, etc.) is touched during BattleManagerBase ctor.
|
|
// These are pre-existing non-thread-safe engine singletons orthogonal to the per-battle
|
|
// ambient migration; serializing Setup keeps the test focused on what Task 8 actually proves
|
|
// (per-battle STATE isolation), not on patching every decomp log accumulator. Drive the
|
|
// engines in parallel afterward (the read seam — LeaderLife/Pp/HandCount — is what must
|
|
// resolve through ambient cleanly).
|
|
var engines = new SessionBattleEngine[n];
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
engines[i] = new SessionBattleEngine();
|
|
engines[i].Setup(inputs[i].seed, inputs[i].deckA, inputs[i].deckB);
|
|
}
|
|
|
|
var parallel = await Task.WhenAll(engines.Select(e => Task.Run(() =>
|
|
{
|
|
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<System.InvalidOperationException>(() => GameMgr.GetIns());
|
|
}
|
|
|
|
private static void DriveBasicTurns(SessionBattleEngine e)
|
|
{
|
|
_ = e.LeaderLife(true);
|
|
_ = e.Pp(true);
|
|
_ = e.HandCount(true);
|
|
}
|
|
}
|