using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using TheLivingWorld.Api.Simulation; using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Simulation; namespace TheLivingWorld.Tests; public sealed class WorldSimulationHostTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), $"tlw-sim-{Guid.NewGuid():n}"); private readonly WorldStore _store; private readonly WorldSimulationHost _host; public WorldSimulationHostTests() { _store = new WorldStore( Options.Create(new WorldStorageOptions { RootDirectory = _root }), NullLogger.Instance); _host = new WorldSimulationHost( _store, Options.Create(new SimulationOptions()), NullLogger.Instance); } [Fact] public async Task Startup_seeds_legacy_worlds_without_catching_up_from_created_at() { var legacy = Summary("legacy-11111111", "Legacy") with { CreatedAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(30), Clock = null, LastTickedAt = null, }; await _store.SaveSummaryAsync(legacy); await _host.StartAsync(CancellationToken.None); try { var clock = await WaitForClockAsync(legacy.Id); // Thirty days of catch-up would be centuries of game time; the seed must start at the default // morning and only drift by the handful of real seconds this test takes. Assert.InRange(clock.GameTime, GameTime.DefaultStart, GameTime.DefaultStart.AddHours(1)); var stored = await _store.GetSummaryAsync(legacy.Id); Assert.NotNull(stored?.Clock); Assert.NotNull(stored.LastTickedAt); } finally { await _host.StopAsync(CancellationToken.None); } } [Fact] public void Concurrent_attach_registers_exactly_one_simulation() { var summary = Summary("racy-44444444", "Racy"); // Every loser of the GetOrAdd race owns an Arch world that must be disposed, not dropped. Parallel.For(0, 32, _ => _host.Attach(summary)); Assert.True(_host.IsAttached(summary.Id)); var clock = _host.TryGetClock(summary.Id); Assert.NotNull(clock); Assert.InRange(clock.GameTime, GameTime.DefaultStart, GameTime.DefaultStart.AddMinutes(5)); } [Fact] public void Attach_keeps_the_running_clock_when_called_again_with_a_stale_summary() { var summary = Summary("stale-55555555", "Stale"); _host.Attach(summary); _host.UpdateClock(summary.Id, new UpdateClockRequest { TimeScale = 4, Paused = true }); _host.Attach(summary); var clock = _host.TryGetClock(summary.Id); Assert.NotNull(clock); Assert.Equal(4, clock.TimeScale); Assert.True(clock.Paused); } [Fact] public async Task UpdateClock_changes_pause_and_Detach_stops_simulation() { var summary = Summary("ready-22222222", "Ready"); await _store.SaveSummaryAsync(summary); _host.Attach(summary); var paused = _host.UpdateClock(summary.Id, new UpdateClockRequest { Paused = true }); Assert.True(paused.Paused); var scaled = _host.UpdateClock(summary.Id, new UpdateClockRequest { TimeScale = 3 }); Assert.Equal(3, scaled.TimeScale); Assert.True(scaled.Paused); _host.Detach(summary.Id); Assert.False(_host.IsAttached(summary.Id)); Assert.Null(_host.TryGetClock(summary.Id)); } /// /// Storage bookkeeping cannot leak here by construction - has no field to /// hold it - so what is left to check is that the facts survive the projection and the live values land. /// [Fact] public void Overlay_carries_the_stored_facts_and_the_live_clock() { var summary = Summary("ready-33333333", "Ready"); _host.Attach(summary); var overlaid = _host.Overlay(summary); Assert.Equal(summary.Id, overlaid.Id); Assert.Equal(summary.Name, overlaid.Name); Assert.Equal(summary.SizeMeters, overlaid.SizeMeters); Assert.Equal(summary.CreatedAt, overlaid.CreatedAt); Assert.NotNull(overlaid.Clock); Assert.NotNull(overlaid.Weather); } [Fact] public void Overlay_falls_back_to_the_stored_facts_for_a_world_that_is_not_running() { var pending = Summary("pending-66666666", "Pending") with { Status = WorldStatus.Pending }; var overlaid = _host.Overlay(pending); Assert.Equal(WorldStatus.Pending, overlaid.Status); Assert.Equal(pending.Name, overlaid.Name); // No simulation is running, so there is no live weather to lay over it. Assert.Null(overlaid.Weather); } [Fact] public async Task Reading_a_lazily_ticked_world_reports_the_current_time_not_a_stale_one() { var summary = Summary("lazy-88888888", "Lazy"); await _store.SaveSummaryAsync(summary); _host.Attach(summary); var before = _host.TryGetClock(summary.Id); Assert.NotNull(before); // Nothing ticks it in between; the read itself has to do the skipped work. await Task.Delay(400); var after = _host.TryGetClock(summary.Id); Assert.NotNull(after); Assert.True( after.GameTime > before.GameTime, "A read must bring the world current rather than answering from the last lazy tick."); // 400 ms at x1 is 2 game minutes; allow for scheduling slop either side. var advanced = after.GameTime - before.GameTime; Assert.InRange(advanced, TimeSpan.FromMinutes(1.5), TimeSpan.FromMinutes(6)); } /// /// The tick loop must not recreate the folder of a world deleted underneath it - that would leave a /// state-only husk that lists as Ready but has no map behind it. /// [Fact] public async Task Persisting_a_deleted_world_does_not_resurrect_it() { var summary = Summary("doomed-77777777", "Doomed"); await _store.SaveSummaryAsync(summary); _host.Attach(summary); // Dirty the clock, then delete the world the way the endpoint does not: storage only, still attached. _host.UpdateClock(summary.Id, new UpdateClockRequest { TimeScale = 2 }); Assert.True(_store.Delete(summary.Id)); await _host.StartAsync(CancellationToken.None); await _host.StopAsync(CancellationToken.None); Assert.Null(await _store.GetSummaryAsync(summary.Id)); Assert.False(Directory.Exists(Path.Combine(_root, summary.Id))); Assert.False(_host.IsAttached(summary.Id)); } private async Task WaitForClockAsync(string id) { for (var attempt = 0; attempt < 100; attempt++) { if (_host.TryGetClock(id) is { } clock) return clock; await Task.Delay(50); } throw new TimeoutException($"Simulation for world '{id}' never attached."); } private static StoredWorldDto Summary(string id, string name) => new() { Id = id, Name = name, Latitude = 31.9, Longitude = -100.5, SizeMeters = 10_000, Status = WorldStatus.Ready, CreatedAt = DateTimeOffset.UtcNow, Clock = WorldSimulation.DefaultClock(), LastTickedAt = DateTimeOffset.UtcNow, }; public void Dispose() { // Detach is a no-op for ids this test never attached, so one list covers every case. string[] ids = [ "legacy-11111111", "ready-22222222", "ready-33333333", "racy-44444444", "stale-55555555", "pending-66666666", "doomed-77777777", "lazy-88888888", ]; foreach (var id in ids) _host.Detach(id); if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); GC.SuppressFinalize(this); } }