feat(engine-ambient): add BattleAmbientContext + AsyncLocal scope
Step 1 of the engine multi-instancing migration. Standalone infrastructure; no engine static reads/writes through it yet. Scope is reentrant (restores prior on dispose), AsyncLocal flows across awaits, and isolated between concurrent Task.Run flows. See docs/superpowers/specs/2026-06-07-engine-multi-instancing-design.md. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
SVSim.BattleEngine.Tests/BattleAmbientTests.cs
Normal file
84
SVSim.BattleEngine.Tests/BattleAmbientTests.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
#nullable enable
|
||||
using SVSim.BattleEngine.Ambient;
|
||||
using NUnit.Framework;
|
||||
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));
|
||||
}
|
||||
}
|
||||
48
SVSim.BattleEngine/Shim/BattleAmbientContext.cs
Normal file
48
SVSim.BattleEngine/Shim/BattleAmbientContext.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
// AUTHORED SHIM (not copied). Per-battle ambient context that backs the
|
||||
// AsyncLocal singleton seam for multi-instancing (see docs/superpowers/specs/
|
||||
// 2026-06-07-engine-multi-instancing-design.md). The engine's per-battle
|
||||
// statics (BattleManagerBase.main/IsForecast/IsRandomDraw, GameMgr.GetIns,
|
||||
// Certification.viewer_id, ToolboxGame.RealTimeNetworkAgent,
|
||||
// Data.BattleRecoveryInfo) resolve through Current when set; process-shared
|
||||
// reference data (CardMaster.Default, Data.Master, etc.) stays static.
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace SVSim.BattleEngine.Ambient;
|
||||
|
||||
public sealed class BattleAmbientContext
|
||||
{
|
||||
public BattleManagerBase? Mgr { get; set; }
|
||||
public GameMgr GameMgr { get; init; } = new();
|
||||
public RealTimeNetworkAgent? NetworkAgent { get; set; }
|
||||
public int ViewerId { get; init; } = 1001;
|
||||
public bool IsForecast { get; set; } = true;
|
||||
public bool IsRandomDraw { get; set; } = true;
|
||||
public Wizard.BattleRecoveryInfo? RecoveryInfo { get; set; }
|
||||
}
|
||||
|
||||
public static class BattleAmbient
|
||||
{
|
||||
internal static readonly AsyncLocal<BattleAmbientContext?> _current = new();
|
||||
|
||||
public static BattleAmbientContext? Current => _current.Value;
|
||||
|
||||
public static BattleAmbientContext Require() =>
|
||||
_current.Value ?? throw new InvalidOperationException(
|
||||
"No ambient battle context. Wrap engine entry points in BattleAmbient.Enter(ctx).");
|
||||
|
||||
public static Scope Enter(BattleAmbientContext ctx)
|
||||
{
|
||||
var prior = _current.Value;
|
||||
_current.Value = ctx;
|
||||
return new Scope(prior);
|
||||
}
|
||||
|
||||
public readonly struct Scope : IDisposable
|
||||
{
|
||||
private readonly BattleAmbientContext? _prior;
|
||||
internal Scope(BattleAmbientContext? prior) { _prior = prior; }
|
||||
public void Dispose() => _current.Value = _prior;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user