Follow-up to the multi-instancing migration. Wraps the process-shared engine statics that aren't ambient-fronted but race between concurrent battles: - UnityEngine.Resources._loaded: Dictionary -> ConcurrentDictionary.GetOrAdd (the shared prefab cache keyed by path; concurrent first-misses produced duplicate GameObjects + Dictionary corruption) - UnityEngine.GameObject._components: Dictionary -> ConcurrentDictionary with Interlocked.CompareExchange init (Resources.Load returns SHARED prefab GameObjects, so two engines' Setup() can race on the same _components map — surfaced as "Operations that change non-concurrent collections" crashes during BattleManagerBase ctor's GetComponent<T>() chain) - Wizard.LocalLog: single static lock around all mutating entry points (StringBuilder _lastTraceLogStringBuilder + ~12 mutable string/bool/int scratch fields; serializing the trace-log surface is cheap since logging is not the hot path) Flips SVSim.BattleEngine.Tests assembly Parallelizable scope from Self to Fixtures and restructures MultiInstanceEngineTests.StressN_BaselineMatches so Setup runs INSIDE Task.Run (was previously serialized as a workaround for the LocalLog races). The fixture is also lifted to ParallelScope.All so the two-engines and stress tests can run alongside each other. Suite fully green under fixture parallelism (59/0/2 across 3 consecutive runs); SVSim.UnitTests still 1054/0/0 — true multi-instance correctness is now proved end-to-end in tests rather than gated behind a serial workaround. Manifest sha refresh + new patch artifact for the LocalLog edit (decomp-origin); the two shim files are authored, so no metadata update is needed for them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
100 lines
4.5 KiB
C#
100 lines
4.5 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.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<System.InvalidOperationException>(() => GameMgr.GetIns());
|
|
}
|
|
|
|
private static void DriveBasicTurns(SessionBattleEngine e)
|
|
{
|
|
_ = e.LeaderLife(true);
|
|
_ = e.Pp(true);
|
|
_ = e.HandCount(true);
|
|
}
|
|
}
|