From 4829e8c263ca0351c7c6cff61980717c14c17da2 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Sun, 7 Jun 2026 21:04:21 -0400 Subject: [PATCH] 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) --- .../BattleAmbientTests.cs | 84 +++++++++++++++++++ .../Shim/BattleAmbientContext.cs | 48 +++++++++++ 2 files changed, 132 insertions(+) create mode 100644 SVSim.BattleEngine.Tests/BattleAmbientTests.cs create mode 100644 SVSim.BattleEngine/Shim/BattleAmbientContext.cs diff --git a/SVSim.BattleEngine.Tests/BattleAmbientTests.cs b/SVSim.BattleEngine.Tests/BattleAmbientTests.cs new file mode 100644 index 0000000..0e29127 --- /dev/null +++ b/SVSim.BattleEngine.Tests/BattleAmbientTests.cs @@ -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(() => 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)); + } +} diff --git a/SVSim.BattleEngine/Shim/BattleAmbientContext.cs b/SVSim.BattleEngine/Shim/BattleAmbientContext.cs new file mode 100644 index 0000000..42344df --- /dev/null +++ b/SVSim.BattleEngine/Shim/BattleAmbientContext.cs @@ -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 _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; + } +}