From 3610ee8051904a93f022a80a6f6845b0f8ebde51 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 16 Aug 2026 22:02:45 +0300 Subject: [PATCH] Enhance world simulation and API functionality; implement clock management improvements, ensure proper handling of world states during updates, and refine data persistence methods to prevent resurrecting deleted worlds. --- .../Endpoints/WorldEndpoints.cs | 3 +- .../Simulation/WorldSimulation.cs | 9 +- .../Simulation/WorldSimulationHost.cs | 72 ++++++-- src/TheLivingWorld.Api/Storage/WorldStore.cs | 56 +++++- .../Simulation/ClockSystem.cs | 4 +- .../Simulation/GameTime.cs | 12 +- .../src/ui/gameTime.test.ts | 27 +++ src/TheLivingWorld.Web/src/ui/gameTime.ts | 11 +- tests/TheLivingWorld.Tests/GameTimeTests.cs | 26 ++- .../WorldSimulationHostTests.cs | 160 +++++++++++++----- .../WorldSimulationTests.cs | 19 +++ tests/TheLivingWorld.Tests/WorldStoreTests.cs | 33 ++++ 12 files changed, 352 insertions(+), 80 deletions(-) diff --git a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs index 2fa04be..dd5d4b9 100644 --- a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs +++ b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs @@ -51,12 +51,13 @@ public static class WorldEndpoints private static async Task CreateWorld( CreateWorldRequest request, WorldGenerationService generation, + WorldSimulationHost simulation, CancellationToken cancellationToken) { try { var summary = await generation.StartAsync(request, cancellationToken); - return Results.Created($"/api/worlds/{summary.Id}", summary with { LastTickedAt = null }); + return Results.Created($"/api/worlds/{summary.Id}", simulation.Overlay(summary)); } catch (ArgumentException ex) { diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs index e73f665..0f7ee28 100644 --- a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs @@ -11,8 +11,6 @@ namespace TheLivingWorld.Api.Simulation; /// public sealed class WorldSimulation : IDisposable { - private static readonly ClockSystem ClockSystem = new(); - private readonly object _gate = new(); private readonly World _ecs; private readonly Entity _clockEntity; @@ -85,10 +83,11 @@ public sealed class WorldSimulation : IDisposable if (realElapsed > TimeSpan.Zero) { - var before = SnapshotClockUnlocked(); + // Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would + // allocate for nothing. + var before = _ecs.Get(_clockEntity).Ticks; ClockSystem.Execute(_ecs, realElapsed); - var after = SnapshotClockUnlocked(); - if (after.GameTime != before.GameTime) _dirty = true; + if (_ecs.Get(_clockEntity).Ticks != before) _dirty = true; } _lastTickedAt = DateTimeOffset.UtcNow; diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs index cb4765f..6161b8f 100644 --- a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs @@ -24,14 +24,10 @@ public sealed class WorldSimulationHost( ArgumentNullException.ThrowIfNull(summary); if (summary.Status != WorldStatus.Ready) return; - _simulations.GetOrAdd(summary.Id, _ => - { - var hydrated = EnsureClock(summary); - // Catch up only when we have a prior tick stamp (restart). Fresh Ready worlds start clean. - var catchUp = summary.Clock is not null && summary.LastTickedAt is not null; + // Catch up only when we have a prior tick stamp (restart). Fresh Ready worlds start clean. + var catchUp = summary.Clock is not null && summary.LastTickedAt is not null; + if (AttachCore(EnsureClock(summary), catchUp)) logger.LogInformation("Attached simulation for world {Id}", summary.Id); - return WorldSimulation.Create(hydrated, catchUp); - }); } /// Stops simulation for a world. Safe to call when the world was never attached. @@ -47,6 +43,11 @@ public sealed class WorldSimulationHost( public WorldClockDto? TryGetClock(string id) => _simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotClock() : null; + /// + /// The one projection from stored/in-flight state to what clients see: overlays the live clock when the + /// world is running and always drops , which is storage-only. + /// Every endpoint that returns a summary must go through here. + /// public WorldSummaryDto Overlay(WorldSummaryDto summary) => _simulations.TryGetValue(summary.Id, out var simulation) ? simulation.OverlayForApi(summary) @@ -109,13 +110,22 @@ public sealed class WorldSimulationHost( public override async Task StopAsync(CancellationToken cancellationToken) { - await PersistDirtyAsync(cancellationToken).ConfigureAwait(false); + // Let the tick loop finish first so the final write captures the very last game time, then persist + // with an uncancellable token: by the time shutdown reaches us the caller's token is usually already + // cancelled, and skipping this write would drop up to a full persist interval of play. await base.StopAsync(cancellationToken).ConfigureAwait(false); + await PersistDirtyAsync(CancellationToken.None, waitForGate: true).ConfigureAwait(false); foreach (var id in _simulations.Keys.ToArray()) Detach(id); + } + // Not in StopAsync: a shutdown that hits its timeout returns from base.StopAsync with the tick loop still + // running, and disposing the gate underneath it would fault the service. The container disposes us last. + public override void Dispose() + { _persistGate.Dispose(); + base.Dispose(); } private async Task LoadReadyWorldsAsync(CancellationToken cancellationToken) @@ -160,11 +170,24 @@ public sealed class WorldSimulationHost( private void AttachSeeded(WorldSummaryDto summary) { - _simulations.GetOrAdd(summary.Id, _ => - { + if (AttachCore(summary, catchUp: false)) logger.LogInformation("Attached simulation for world {Id} (hydrated, no catch-up)", summary.Id); - return WorldSimulation.Create(summary, catchUp: false); - }); + } + + /// + /// Registers a simulation for a world, returning false when one was already running. A losing racer must + /// be disposed explicitly: an Arch World lives in a static registry and is never reclaimed by the + /// GC, so dropping the instance would leak it for the lifetime of the process. + /// + private bool AttachCore(WorldSummaryDto summary, bool catchUp) + { + if (_simulations.ContainsKey(summary.Id)) return false; + + var created = WorldSimulation.Create(summary, catchUp); + if (ReferenceEquals(_simulations.GetOrAdd(summary.Id, created), created)) return true; + + created.Dispose(); + return false; } private static WorldSummaryDto EnsureClock(WorldSummaryDto summary) => @@ -176,9 +199,15 @@ public sealed class WorldSimulationHost( LastTickedAt = DateTimeOffset.UtcNow, }; - private async Task PersistDirtyAsync(CancellationToken cancellationToken) + /// + /// False for the periodic pass - a persist already in flight is as good as ours. True on shutdown, where + /// the final write must not be skipped. + /// + private async Task PersistDirtyAsync(CancellationToken cancellationToken, bool waitForGate = false) { - if (!await _persistGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + if (waitForGate) + await _persistGate.WaitAsync(cancellationToken).ConfigureAwait(false); + else if (!await _persistGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) return; try @@ -190,15 +219,24 @@ public sealed class WorldSimulationHost( try { var existing = await store.GetSummaryAsync(simulation.WorldId, cancellationToken).ConfigureAwait(false); - if (existing is null || existing.Status != WorldStatus.Ready) + if (existing is null) + { + // Deleted underneath us - stop burning ticks on a world that no longer exists. + Detach(simulation.WorldId); + continue; + } + + if (existing.Status != WorldStatus.Ready) { simulation.ClearDirty(); continue; } var updated = simulation.ApplyTo(existing); - await store.SaveSummaryAsync(updated, cancellationToken).ConfigureAwait(false); - simulation.ClearDirty(); + if (await store.TryUpdateSummaryAsync(updated, cancellationToken).ConfigureAwait(false)) + simulation.ClearDirty(); + else + Detach(simulation.WorldId); } catch (Exception ex) { diff --git a/src/TheLivingWorld.Api/Storage/WorldStore.cs b/src/TheLivingWorld.Api/Storage/WorldStore.cs index 3bd59e4..25ac7b3 100644 --- a/src/TheLivingWorld.Api/Storage/WorldStore.cs +++ b/src/TheLivingWorld.Api/Storage/WorldStore.cs @@ -89,6 +89,28 @@ public sealed class WorldStore(IOptions options, ILogger + /// Updates an existing world's state file without ever creating its folder. Returns false when the world + /// was deleted underneath the caller, so a background writer cannot resurrect it as a stateful husk with + /// no map behind it. + /// + public async Task TryUpdateSummaryAsync( + WorldSummaryDto summary, + CancellationToken cancellationToken = default) + { + var path = Path.Combine(WorldDirectory(summary.Id), StateFileName); + + try + { + await WriteJsonAsync(path, summary, cancellationToken).ConfigureAwait(false); + return true; + } + catch (DirectoryNotFoundException) + { + return false; + } + } + public async Task GetWorldAsync(string id, CancellationToken cancellationToken = default) { var path = Path.Combine(WorldDirectory(id), IndexFileName); @@ -148,13 +170,33 @@ public sealed class WorldStore(IOptions options, ILogger(string path, T value, CancellationToken cancellationToken) { - // Write beside the target and swap, so a reader never sees a half-written file. - var temporary = path + ".tmp"; - await using (var stream = File.Create(temporary)) - { - await JsonSerializer.SerializeAsync(stream, value, MapJson.Options, cancellationToken).ConfigureAwait(false); - } + // Write beside the target and swap, so a reader never sees a half-written file. The name carries + // entropy: state.json now has two writers (generation and the simulation host) that must not + // collide on a shared scratch file. + var temporary = $"{path}.{Guid.NewGuid():n}.tmp"; - File.Move(temporary, path, overwrite: true); + try + { + await using (var stream = File.Create(temporary)) + { + await JsonSerializer.SerializeAsync(stream, value, MapJson.Options, cancellationToken).ConfigureAwait(false); + } + + File.Move(temporary, path, overwrite: true); + } + catch + { + // A unique name never gets reclaimed by the next write, so clean up after ourselves. + try + { + File.Delete(temporary); + } + catch (IOException) + { + // Best effort - the original failure is what matters. + } + + throw; + } } } diff --git a/src/TheLivingWorld.Core/Simulation/ClockSystem.cs b/src/TheLivingWorld.Core/Simulation/ClockSystem.cs index 66cafaa..7d98355 100644 --- a/src/TheLivingWorld.Core/Simulation/ClockSystem.cs +++ b/src/TheLivingWorld.Core/Simulation/ClockSystem.cs @@ -4,11 +4,11 @@ using TheLivingWorld.Core.Ecs; namespace TheLivingWorld.Core.Simulation; /// Advances every entity by wall-clock elapsed time. -public sealed class ClockSystem +public static class ClockSystem { private static readonly QueryDescription Clocks = new QueryDescription().WithAll(); - public void Execute(World ecs, TimeSpan realElapsed) + public static void Execute(World ecs, TimeSpan realElapsed) { if (realElapsed <= TimeSpan.Zero) return; diff --git a/src/TheLivingWorld.Core/Simulation/GameTime.cs b/src/TheLivingWorld.Core/Simulation/GameTime.cs index 9904c65..16fe748 100644 --- a/src/TheLivingWorld.Core/Simulation/GameTime.cs +++ b/src/TheLivingWorld.Core/Simulation/GameTime.cs @@ -13,7 +13,8 @@ public static class GameTime /// /// Advances a naive game calendar by wall-clock elapsed time. Returns unchanged - /// when paused or when elapsed is non-positive. + /// when paused or when elapsed is non-positive; saturates at rather than + /// throwing, so a world started near the end of the calendar cannot fault its tick loop. /// public static DateTime Advance(DateTime current, TimeSpan realElapsed, int timeScale, bool paused) { @@ -22,7 +23,14 @@ public static class GameTime var scale = Math.Clamp(timeScale, MinTimeScale, MaxTimeScale); // 1 real second → 5 * scale game minutes. Multiply by 60 (minutes→seconds) in tick space, // keeping the product in range for multi-hour catch-up gaps. - var gameTicks = checked(realElapsed.Ticks * GameMinutesPerRealSecond * scale * 60); + var factor = (long)GameMinutesPerRealSecond * scale * 60; + var headroom = (DateTime.MaxValue - current).Ticks; + + // Compare before multiplying: the product itself would overflow on an absurd gap. + var gameTicks = realElapsed.Ticks > headroom / factor + ? headroom + : realElapsed.Ticks * factor; + return current.AddTicks(gameTicks); } diff --git a/src/TheLivingWorld.Web/src/ui/gameTime.test.ts b/src/TheLivingWorld.Web/src/ui/gameTime.test.ts index 9aa2a51..f48f400 100644 --- a/src/TheLivingWorld.Web/src/ui/gameTime.test.ts +++ b/src/TheLivingWorld.Web/src/ui/gameTime.test.ts @@ -24,6 +24,12 @@ describe('parseGameTime', () => { expect(parseGameTime('')).toBeNull(); expect(parseGameTime('not-a-date')).toBeNull(); }); + + it('rejects out-of-range components instead of rolling them over', () => { + expect(parseGameTime('2012-04-12T06:00:60')).toBeNull(); + expect(parseGameTime('2012-04-12T06:60:00')).toBeNull(); + expect(parseGameTime('2012-02-30T06:00:00')).toBeNull(); + }); }); describe('formatGameTime', () => { @@ -65,6 +71,27 @@ describe('interpolateGameTime', () => { expect(date).not.toBeNull(); expect(formatGameTime(date!)).toBe('12 April 2012 · 06:10'); }); + + it('clamps timeScale the way the server does', () => { + const tooFast = interpolateGameTime( + { gameTime: '2012-04-12T06:00:00', timeScale: 99, paused: false }, + 1000, + ); + expect(formatGameTime(tooFast!)).toBe('12 April 2012 · 06:20'); + + const tooSlow = interpolateGameTime( + { gameTime: '2012-04-12T06:00:00', timeScale: 0, paused: false }, + 1000, + ); + expect(formatGameTime(tooSlow!)).toBe('12 April 2012 · 06:05'); + }); +}); + +describe('startGameTimeFromInput', () => { + it('rejects out-of-range components', () => { + expect(startGameTimeFromInput('2012-04-12T06:60')).toBeNull(); + expect(startGameTimeFromInput('2012-04-12T06:00:60')).toBeNull(); + }); }); describe('startGameTimeFromInput', () => { diff --git a/src/TheLivingWorld.Web/src/ui/gameTime.ts b/src/TheLivingWorld.Web/src/ui/gameTime.ts index 26d26a4..fee0091 100644 --- a/src/TheLivingWorld.Web/src/ui/gameTime.ts +++ b/src/TheLivingWorld.Web/src/ui/gameTime.ts @@ -3,6 +3,10 @@ import type { WorldClock } from '../api/types'; /** Matches server GameTime: five game minutes per real second at x1. */ export const GAME_MINUTES_PER_REAL_SECOND = 5; +/** Mirrors GameTime.MinTimeScale / MaxTimeScale, which the server clamps to. */ +export const MIN_TIME_SCALE = 1; +export const MAX_TIME_SCALE = 4; + const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', @@ -30,6 +34,7 @@ export function parseGameTime(raw: string): Date | null { || date.getDate() !== day || date.getHours() !== hour || date.getMinutes() !== minute + || date.getSeconds() !== second ) { return null; } @@ -64,7 +69,10 @@ export function interpolateGameTime( if (!base) return null; if (clock.paused || elapsedRealMs <= 0) return base; - const scale = Number.isFinite(clock.timeScale) ? clock.timeScale : 1; + // Clamp like the server does, so a contract drift cannot make the client draw a time nobody is simulating. + const scale = Number.isFinite(clock.timeScale) + ? Math.min(Math.max(clock.timeScale, MIN_TIME_SCALE), MAX_TIME_SCALE) + : MIN_TIME_SCALE; const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000; return new Date(base.getTime() + gameMs); } @@ -97,6 +105,7 @@ export function startGameTimeFromInput(raw: string): string | null { || date.getDate() !== day || date.getHours() !== hour || date.getMinutes() !== minute + || date.getSeconds() !== second ) { return null; } diff --git a/tests/TheLivingWorld.Tests/GameTimeTests.cs b/tests/TheLivingWorld.Tests/GameTimeTests.cs index 15f1990..aab5d77 100644 --- a/tests/TheLivingWorld.Tests/GameTimeTests.cs +++ b/tests/TheLivingWorld.Tests/GameTimeTests.cs @@ -71,7 +71,31 @@ public sealed class GameTimeTests [Fact] public void ResolveStart_rejects_out_of_range_years() { + // Year 10000 cannot even be constructed, so this has to sit just past the accepted range to + // actually exercise ResolveStart. Assert.Throws(() => - GameTime.ResolveStart(new DateTime(10000, 1, 1))); + GameTime.ResolveStart(new DateTime(9999, 1, 1, 0, 0, 0, DateTimeKind.Unspecified))); + } + + [Fact] + public void Advance_saturates_instead_of_running_off_the_end_of_the_calendar() + { + var nearTheEnd = new DateTime(9998, 12, 31, 23, 0, 0, DateTimeKind.Unspecified); + + // A day of real time at x4 is roughly 16 game years - far past DateTime.MaxValue from here. + var next = GameTime.Advance(nearTheEnd, TimeSpan.FromDays(1), timeScale: 4, paused: false); + + Assert.Equal(DateTime.MaxValue, next); + // Already saturated: the next tick must stay put rather than throw. + Assert.Equal(DateTime.MaxValue, GameTime.Advance(next, TimeSpan.FromSeconds(1), 1, paused: false)); + } + + [Fact] + public void Advance_survives_an_absurd_wall_clock_gap() + { + // The naive product realElapsed.Ticks * 1200 overflows a long well before TimeSpan.MaxValue. + var next = GameTime.Advance(GameTime.DefaultStart, TimeSpan.MaxValue, timeScale: 4, paused: false); + + Assert.Equal(DateTime.MaxValue, next); } } diff --git a/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs b/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs index 941f673..2f3e420 100644 --- a/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs +++ b/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs @@ -22,51 +22,69 @@ public sealed class WorldSimulationHostTests : IDisposable } [Fact] - public async Task Attach_hydrates_legacy_worlds_without_catch_up_from_created_at() + public async Task Startup_seeds_legacy_worlds_without_catching_up_from_created_at() { - var legacy = new WorldSummaryDto + var legacy = Summary("legacy-11111111", "Legacy") with { - Id = "legacy-11111111", - Name = "Legacy", - Latitude = 31.9, - Longitude = -100.5, - SizeMeters = 10_000, - Status = WorldStatus.Ready, CreatedAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(30), + Clock = null, + LastTickedAt = null, }; await _store.SaveSummaryAsync(legacy); - // Mimic host startup hydration for worlds that lack a clock. - var seeded = legacy with + await _host.StartAsync(CancellationToken.None); + try { - Clock = WorldSimulation.DefaultClock(), - LastTickedAt = DateTimeOffset.UtcNow, - }; - await _store.SaveSummaryAsync(seeded); - _host.Attach(seeded with { LastTickedAt = null }); // Attach without prior tick → no catch-up + var clock = await WaitForClockAsync(legacy.Id); - // Force attach path used for legacy: Create with catchUp false via AttachSeeded behaviour — - // Attach with LastTickedAt null means catchUp is false. - var clock = _host.TryGetClock(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.Equal(GameTime.DefaultStart, clock.GameTime); + 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 = new WorldSummaryDto - { - Id = "ready-22222222", - Name = "Ready", - Latitude = 31.9, - Longitude = -100.5, - SizeMeters = 10_000, - Status = WorldStatus.Ready, - CreatedAt = DateTimeOffset.UtcNow, - Clock = WorldSimulation.DefaultClock(), - LastTickedAt = DateTimeOffset.UtcNow, - }; + var summary = Summary("ready-22222222", "Ready"); await _store.SaveSummaryAsync(summary); _host.Attach(summary); @@ -85,18 +103,7 @@ public sealed class WorldSimulationHostTests : IDisposable [Fact] public void Overlay_strips_last_ticked_at_for_api() { - var summary = new WorldSummaryDto - { - Id = "ready-33333333", - Name = "Ready", - Latitude = 31.9, - Longitude = -100.5, - SizeMeters = 10_000, - Status = WorldStatus.Ready, - CreatedAt = DateTimeOffset.UtcNow, - Clock = WorldSimulation.DefaultClock(), - LastTickedAt = DateTimeOffset.UtcNow, - }; + var summary = Summary("ready-33333333", "Ready"); _host.Attach(summary); var overlaid = _host.Overlay(summary); @@ -104,9 +111,74 @@ public sealed class WorldSimulationHostTests : IDisposable Assert.Null(overlaid.LastTickedAt); } + [Fact] + public void Overlay_strips_last_ticked_at_for_worlds_that_are_not_running() + { + var overlaid = _host.Overlay(Summary("pending-66666666", "Pending") with + { + Status = WorldStatus.Pending, + }); + + Assert.Null(overlaid.LastTickedAt); + } + + /// + /// 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 WorldSummaryDto 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() { - foreach (var id in new[] { "legacy-11111111", "ready-22222222", "ready-33333333" }) + // 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", + ]; + + foreach (var id in ids) _host.Detach(id); if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); diff --git a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs index c472acc..156f7f2 100644 --- a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs +++ b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs @@ -80,6 +80,25 @@ public sealed class WorldSimulationTests GameTime.DefaultStart.AddMinutes(17)); } + [Fact] + public void Catch_up_skips_a_world_that_was_paused_when_the_host_went_down() + { + var summary = ReadySummary() with + { + Clock = WorldSimulation.DefaultClock() with { Paused = true }, + LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromHours(6), + }; + + using var simulation = WorldSimulation.Create(summary, catchUp: true); + + Assert.Equal(GameTime.DefaultStart, simulation.SnapshotClock().GameTime); + + // Unpausing must not replay the six hours spent paused offline either. + var resumed = simulation.Update(new UpdateClockRequest { Paused = false }); + Assert.False(resumed.Paused); + Assert.InRange(resumed.GameTime, GameTime.DefaultStart, GameTime.DefaultStart.AddMinutes(5)); + } + [Fact] public void OverlayForApi_strips_last_ticked_at() { diff --git a/tests/TheLivingWorld.Tests/WorldStoreTests.cs b/tests/TheLivingWorld.Tests/WorldStoreTests.cs index 813f7ec..fc40520 100644 --- a/tests/TheLivingWorld.Tests/WorldStoreTests.cs +++ b/tests/TheLivingWorld.Tests/WorldStoreTests.cs @@ -47,6 +47,39 @@ public sealed class WorldStoreTests : IDisposable Assert.Equal(1, await _store.CountAsync()); } + [Fact] + public async Task TryUpdateSummaryAsync_writes_over_an_existing_state_file() + { + var summary = Summary("alpha-11111111", "Alpha"); + await _store.SaveSummaryAsync(summary); + + Assert.True(await _store.TryUpdateSummaryAsync(summary with { Name = "Renamed" })); + Assert.Equal("Renamed", (await _store.GetSummaryAsync(summary.Id))?.Name); + } + + [Fact] + public async Task TryUpdateSummaryAsync_refuses_to_recreate_a_deleted_world() + { + var summary = Summary("alpha-11111111", "Alpha"); + await _store.SaveSummaryAsync(summary); + Assert.True(_store.Delete(summary.Id)); + + Assert.False(await _store.TryUpdateSummaryAsync(summary)); + Assert.False(Directory.Exists(Path.Combine(_root, summary.Id))); + Assert.Equal(0, await _store.CountAsync()); + } + + [Fact] + public async Task SaveSummaryAsync_leaves_no_scratch_files_behind() + { + var summary = Summary("alpha-11111111", "Alpha"); + await _store.SaveSummaryAsync(summary); + await _store.SaveSummaryAsync(summary with { Name = "Alpha again" }); + + var files = Directory.GetFiles(Path.Combine(_root, summary.Id)); + Assert.Equal(["state.json"], files.Select(static path => Path.GetFileName(path)).Order().ToArray()!); + } + private static WorldSummaryDto Summary(string id, string name) => new() { Id = id,