From 3b7fca14959b5fa3eb7da6bd20978d051f5b745c Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 16 Aug 2026 19:31:12 +0300 Subject: [PATCH] Implement live game clock and simulation controls; enhance API with clock update functionality and improve world summary with clock data; update UI to display simulation controls and integrate clock features into the game experience. --- AGENTS.md | 4 +- README.md | 1 + .../Endpoints/WorldEndpoints.cs | 74 +++++- .../Generation/WorldGenerationService.cs | 6 + src/TheLivingWorld.Api/Program.cs | 3 + .../Simulation/WorldSimulation.cs | 201 ++++++++++++++++ .../Simulation/WorldSimulationHost.cs | 214 ++++++++++++++++++ .../Contracts/WorldContracts.cs | 28 +++ src/TheLivingWorld.Core/Ecs/Components.cs | 6 + .../Simulation/ClockSystem.cs | 22 ++ .../Simulation/GameTime.cs | 31 +++ src/TheLivingWorld.Web/index.html | 16 ++ src/TheLivingWorld.Web/src/api/client.ts | 17 +- src/TheLivingWorld.Web/src/api/types.ts | 13 ++ src/TheLivingWorld.Web/src/main.ts | 193 +++++++++++++++- src/TheLivingWorld.Web/src/styles.css | 56 +++++ .../src/ui/gameTime.test.ts | 66 ++++++ src/TheLivingWorld.Web/src/ui/gameTime.ts | 70 ++++++ tests/TheLivingWorld.Tests/GameTimeTests.cs | 61 +++++ .../WorldGenerationServiceTests.cs | 2 + .../WorldSimulationHostTests.cs | 115 ++++++++++ .../WorldSimulationTests.cs | 105 +++++++++ 22 files changed, 1290 insertions(+), 14 deletions(-) create mode 100644 src/TheLivingWorld.Api/Simulation/WorldSimulation.cs create mode 100644 src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs create mode 100644 src/TheLivingWorld.Core/Simulation/ClockSystem.cs create mode 100644 src/TheLivingWorld.Core/Simulation/GameTime.cs create mode 100644 src/TheLivingWorld.Web/src/ui/gameTime.test.ts create mode 100644 src/TheLivingWorld.Web/src/ui/gameTime.ts create mode 100644 tests/TheLivingWorld.Tests/GameTimeTests.cs create mode 100644 tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs create mode 100644 tests/TheLivingWorld.Tests/WorldSimulationTests.cs diff --git a/AGENTS.md b/AGENTS.md index e12b4e9..24962c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,9 @@ The Living World is a web game whose map is a real place: the API imports OpenSt | Aspire host | `src/TheLivingWorld.AppHost` | | Tests | `tests/TheLivingWorld.Tests` (xUnit), Vitest under the web project | -Worlds are stored as files under `data/` (not committed). Cap: `WorldStorage:MaxConcurrentWorlds`. UI: main menu (list + create) → map screen. +Worlds are stored as files under `data/` (not committed). Cap: `WorldStorage:MaxConcurrentWorlds`. +Ready worlds run a live game clock on the server (5 game minutes per real second at x1; default start +12 April 2012 06:00). UI: main menu (list + create) → map screen with pause and speed controls. Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`. diff --git a/README.md b/README.md index 29eafe3..674b521 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,7 @@ them without reworking the data model. | `GET /api/worlds/{id}` | Status of one world | | `GET /api/worlds/{id}/map` | Metadata plus the chunk index | | `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry | +| `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 1–4). Body: `{ paused?, timeScale? }` | | `DELETE /api/worlds/{id}` | Remove a world and its chunks | Generation takes tens of seconds — mostly waiting on Overpass — so `POST` returns straight away and the client diff --git a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs index beb9804..2fa04be 100644 --- a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs +++ b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs @@ -1,6 +1,8 @@ using TheLivingWorld.Api.Generation; +using TheLivingWorld.Api.Simulation; using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Simulation; namespace TheLivingWorld.Api.Endpoints; @@ -15,6 +17,7 @@ public static class WorldEndpoints worlds.MapGet("/{id}", GetWorld); worlds.MapGet("/{id}/map", GetMap); worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk); + worlds.MapPatch("/{id}/clock", UpdateClock); worlds.MapDelete("/{id}", DeleteWorld); return app; @@ -23,13 +26,19 @@ public static class WorldEndpoints private static async Task ListWorlds( WorldStore store, WorldGenerationService generation, + WorldSimulationHost simulation, CancellationToken cancellationToken) { var stored = await store.ListAsync(cancellationToken); // A world being generated right now has a fresher status in memory than on disk. + // Ready worlds overlay the live clock from the simulation host. var merged = stored - .Select(summary => generation.GetInFlight(summary.Id) ?? summary) + .Select(summary => + { + var live = generation.GetInFlight(summary.Id) ?? summary; + return simulation.Overlay(live); + }) .ToArray(); return Results.Ok(new WorldListDto @@ -47,7 +56,7 @@ public static class WorldEndpoints try { var summary = await generation.StartAsync(request, cancellationToken); - return Results.Created($"/api/worlds/{summary.Id}", summary); + return Results.Created($"/api/worlds/{summary.Id}", summary with { LastTickedAt = null }); } catch (ArgumentException ex) { @@ -71,14 +80,16 @@ public static class WorldEndpoints string id, WorldStore store, WorldGenerationService generation, + WorldSimulationHost simulation, CancellationToken cancellationToken) { if (!WorldStore.IsValidId(id)) return Results.NotFound(); - if (generation.GetInFlight(id) is { } live) return Results.Ok(live); + if (generation.GetInFlight(id) is { } live) + return Results.Ok(simulation.Overlay(live)); var summary = await store.GetSummaryAsync(id, cancellationToken); - return summary is null ? Results.NotFound() : Results.Ok(summary); + return summary is null ? Results.NotFound() : Results.Ok(simulation.Overlay(summary)); } private static async Task GetMap(string id, WorldStore store, CancellationToken cancellationToken) @@ -101,9 +112,62 @@ public static class WorldEndpoints return Results.Stream(stream, "application/json"); } - private static IResult DeleteWorld(string id, WorldStore store) + private static async Task UpdateClock( + string id, + UpdateClockRequest request, + WorldStore store, + WorldSimulationHost simulation, + CancellationToken cancellationToken) { if (!WorldStore.IsValidId(id)) return Results.NotFound(); + + if (request.TimeScale is { } scale && !GameTime.IsValidTimeScale(scale)) + { + return Results.ValidationProblem(new Dictionary + { + ["timeScale"] = [$"TimeScale must be {GameTime.MinTimeScale}..{GameTime.MaxTimeScale}."], + }); + } + + var summary = await store.GetSummaryAsync(id, cancellationToken); + if (summary is null) return Results.NotFound(); + if (summary.Status != WorldStatus.Ready) + { + return Results.ValidationProblem(new Dictionary + { + ["id"] = ["Clock controls are only available when the world is ready."], + }); + } + + if (!simulation.IsAttached(id)) + simulation.Attach(summary); + + try + { + var clock = simulation.UpdateClock(id, request); + return Results.Ok(clock); + } + catch (ArgumentOutOfRangeException ex) + { + return Results.ValidationProblem(new Dictionary + { + ["timeScale"] = [ex.Message], + }); + } + catch (InvalidOperationException ex) + { + return Results.ValidationProblem(new Dictionary + { + ["id"] = [ex.Message], + }); + } + } + + private static IResult DeleteWorld(string id, WorldStore store, WorldSimulationHost simulation) + { + if (!WorldStore.IsValidId(id)) return Results.NotFound(); + + simulation.Detach(id); return store.Delete(id) ? Results.NoContent() : Results.NotFound(); } } diff --git a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs index 630e72d..4e29d2a 100644 --- a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs +++ b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Globalization; using System.Text; using Microsoft.Extensions.Options; +using TheLivingWorld.Api.Simulation; using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Export; @@ -20,6 +21,7 @@ public sealed class WorldGenerationService( OsmWorldGenerator generator, WorldStore store, ChunkExporter exporter, + WorldSimulationHost simulation, IOptions storageOptions, IHostApplicationLifetime lifetime, ILogger logger) : IDisposable @@ -59,6 +61,7 @@ public sealed class WorldGenerationService( Status = WorldStatus.Pending, Stage = "Queued", CreatedAt = DateTimeOffset.UtcNow, + Clock = WorldSimulation.DefaultClock(), }; await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); @@ -146,9 +149,12 @@ public sealed class WorldGenerationService( Water = stats.Water, Vertices = stats.Vertices, }, + Clock = summary.Clock ?? WorldSimulation.DefaultClock(), + LastTickedAt = DateTimeOffset.UtcNow, }; await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false); + simulation.Attach(ready); Publish(ready); _inFlight.TryRemove(summary.Id, out _); } diff --git a/src/TheLivingWorld.Api/Program.cs b/src/TheLivingWorld.Api/Program.cs index 08483f6..5b87edc 100644 --- a/src/TheLivingWorld.Api/Program.cs +++ b/src/TheLivingWorld.Api/Program.cs @@ -2,6 +2,7 @@ using System.IO.Compression; using Microsoft.AspNetCore.ResponseCompression; using TheLivingWorld.Api.Endpoints; using TheLivingWorld.Api.Generation; +using TheLivingWorld.Api.Simulation; using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Export; @@ -35,6 +36,8 @@ builder.Services.PostConfigure(options => builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(provider => provider.GetRequiredService()); builder.Services.AddSingleton(); // Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude. diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs new file mode 100644 index 0000000..83f1aa8 --- /dev/null +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs @@ -0,0 +1,201 @@ +using Arch.Core; +using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Ecs; +using TheLivingWorld.Core.Simulation; + +namespace TheLivingWorld.Api.Simulation; + +/// +/// Lightweight live runtime for one world: an Arch world holding a single entity. +/// Map geometry stays on disk; only the clock (and later sim state) lives here. +/// +public sealed class WorldSimulation : IDisposable +{ + private static readonly ClockSystem ClockSystem = new(); + + private readonly object _gate = new(); + private readonly World _ecs; + private readonly Entity _clockEntity; + private DateTimeOffset _lastTickedAt; + private bool _dirty; + private bool _disposed; + + private WorldSimulation(string worldId, World ecs, Entity clockEntity, DateTimeOffset lastTickedAt) + { + WorldId = worldId; + _ecs = ecs; + _clockEntity = clockEntity; + _lastTickedAt = lastTickedAt; + } + + public string WorldId { get; } + + public bool IsDirty + { + get + { + lock (_gate) return _dirty; + } + } + + /// + /// Builds a simulation from persisted summary state. When is true and the + /// clock is not paused, advances for the wall-clock gap since . + /// + public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true) + { + ArgumentNullException.ThrowIfNull(summary); + + var clock = summary.Clock ?? DefaultClock(); + var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale; + var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified); + + var ecs = World.Create(); + var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused)); + var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow; + + var simulation = new WorldSimulation(summary.Id, ecs, entity, lastTickedAt); + + if (catchUp && !clock.Paused) + { + var gap = DateTimeOffset.UtcNow - lastTickedAt; + if (gap > TimeSpan.Zero) simulation.Tick(gap); + } + else + { + // Align wall-clock so a later unpause does not replay time spent paused offline. + simulation._lastTickedAt = DateTimeOffset.UtcNow; + } + + return simulation; + } + + public static WorldClockDto DefaultClock() => new() + { + GameTime = GameTime.DefaultStart, + TimeScale = GameTime.MinTimeScale, + Paused = false, + }; + + public void Tick(TimeSpan realElapsed) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + if (realElapsed > TimeSpan.Zero) + { + var before = SnapshotClockUnlocked(); + ClockSystem.Execute(_ecs, realElapsed); + var after = SnapshotClockUnlocked(); + if (after.GameTime != before.GameTime) _dirty = true; + } + + _lastTickedAt = DateTimeOffset.UtcNow; + } + } + + public WorldClockDto SnapshotClock() + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return SnapshotClockUnlocked(); + } + } + + public DateTimeOffset LastTickedAt + { + get + { + lock (_gate) return _lastTickedAt; + } + } + + /// + /// Applies pause / time-scale changes. Elapsed time on the previous settings is baked in first so the + /// switch is instantaneous from the player's point of view. + /// + public WorldClockDto Update(UpdateClockRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + + var now = DateTimeOffset.UtcNow; + var gap = now - _lastTickedAt; + if (gap > TimeSpan.Zero) ClockSystem.Execute(_ecs, gap); + + ref var clock = ref _ecs.Get(_clockEntity); + + if (request.TimeScale is { } scale) + { + if (!GameTime.IsValidTimeScale(scale)) + throw new ArgumentOutOfRangeException(nameof(request), $"TimeScale must be {GameTime.MinTimeScale}..{GameTime.MaxTimeScale}."); + clock.TimeScale = scale; + } + + if (request.Paused is { } paused) + clock.Paused = paused; + + _lastTickedAt = now; + _dirty = true; + return SnapshotClockUnlocked(); + } + } + + public WorldSummaryDto ApplyTo(WorldSummaryDto summary) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return summary with + { + Clock = SnapshotClockUnlocked(), + LastTickedAt = _lastTickedAt, + }; + } + } + + /// Wire-facing snapshot: live clock, no internal last-tick stamp. + public WorldSummaryDto OverlayForApi(WorldSummaryDto summary) + { + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + return summary with + { + Clock = SnapshotClockUnlocked(), + LastTickedAt = null, + }; + } + } + + public void ClearDirty() + { + lock (_gate) _dirty = false; + } + + private WorldClockDto SnapshotClockUnlocked() + { + ref var clock = ref _ecs.Get(_clockEntity); + return new WorldClockDto + { + GameTime = new DateTime(clock.Ticks, DateTimeKind.Unspecified), + TimeScale = clock.TimeScale, + Paused = clock.Paused, + }; + } + + public void Dispose() + { + lock (_gate) + { + if (_disposed) return; + _disposed = true; + World.Destroy(_ecs); + } + } +} diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs new file mode 100644 index 0000000..cb4765f --- /dev/null +++ b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs @@ -0,0 +1,214 @@ +using System.Collections.Concurrent; +using TheLivingWorld.Api.Storage; +using TheLivingWorld.Core.Contracts; + +namespace TheLivingWorld.Api.Simulation; + +/// +/// Hosts live clock simulations for every Ready world. Ticks ~10 Hz, persists dirty state every few seconds, +/// and catches up wall-clock gaps after restart. +/// +public sealed class WorldSimulationHost( + WorldStore store, + ILogger logger) : BackgroundService +{ + private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan PersistInterval = TimeSpan.FromSeconds(5); + + private readonly ConcurrentDictionary _simulations = new(); + private readonly SemaphoreSlim _persistGate = new(1, 1); + + /// Attaches a Ready world if it is not already running. Idempotent. + public void Attach(WorldSummaryDto summary) + { + 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; + 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. + public void Detach(string id) + { + if (_simulations.TryRemove(id, out var simulation)) + { + simulation.Dispose(); + logger.LogInformation("Detached simulation for world {Id}", id); + } + } + + public WorldClockDto? TryGetClock(string id) => + _simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotClock() : null; + + public WorldSummaryDto Overlay(WorldSummaryDto summary) => + _simulations.TryGetValue(summary.Id, out var simulation) + ? simulation.OverlayForApi(summary) + : summary with { LastTickedAt = null }; + + public WorldClockDto UpdateClock(string id, UpdateClockRequest request) + { + if (!_simulations.TryGetValue(id, out var simulation)) + throw new InvalidOperationException("World is not ready for simulation."); + + return simulation.Update(request); + } + + public bool IsAttached(string id) => _simulations.ContainsKey(id); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await LoadReadyWorldsAsync(stoppingToken).ConfigureAwait(false); + + var lastTick = TimeProvider.System.GetUtcNow(); + var lastPersist = lastTick; + + while (!stoppingToken.IsCancellationRequested) + { + var now = TimeProvider.System.GetUtcNow(); + var elapsed = now - lastTick; + lastTick = now; + + if (elapsed > TimeSpan.Zero) + { + foreach (var simulation in _simulations.Values) + { + try + { + simulation.Tick(elapsed); + } + catch (Exception ex) + { + logger.LogError(ex, "Clock tick failed for world {Id}", simulation.WorldId); + } + } + } + + if (now - lastPersist >= PersistInterval) + { + await PersistDirtyAsync(stoppingToken).ConfigureAwait(false); + lastPersist = TimeProvider.System.GetUtcNow(); + } + + try + { + await Task.Delay(TickInterval, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + } + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + await PersistDirtyAsync(cancellationToken).ConfigureAwait(false); + await base.StopAsync(cancellationToken).ConfigureAwait(false); + + foreach (var id in _simulations.Keys.ToArray()) + Detach(id); + + _persistGate.Dispose(); + } + + private async Task LoadReadyWorldsAsync(CancellationToken cancellationToken) + { + IReadOnlyList listed; + try + { + listed = await store.ListAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not list worlds to start simulations"); + return; + } + + foreach (var summary in listed.Where(static s => s.Status == WorldStatus.Ready)) + { + try + { + if (summary.Clock is null) + { + // Legacy worlds: seed DefaultStart with no catch-up from CreatedAt. + var seeded = summary with + { + Clock = WorldSimulation.DefaultClock(), + LastTickedAt = DateTimeOffset.UtcNow, + }; + await store.SaveSummaryAsync(seeded, cancellationToken).ConfigureAwait(false); + AttachSeeded(seeded); + } + else + { + Attach(summary); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Could not attach simulation for world {Id}", summary.Id); + } + } + } + + private void AttachSeeded(WorldSummaryDto summary) + { + _simulations.GetOrAdd(summary.Id, _ => + { + logger.LogInformation("Attached simulation for world {Id} (hydrated, no catch-up)", summary.Id); + return WorldSimulation.Create(summary, catchUp: false); + }); + } + + private static WorldSummaryDto EnsureClock(WorldSummaryDto summary) => + summary.Clock is not null + ? summary + : summary with + { + Clock = WorldSimulation.DefaultClock(), + LastTickedAt = DateTimeOffset.UtcNow, + }; + + private async Task PersistDirtyAsync(CancellationToken cancellationToken) + { + if (!await _persistGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + return; + + try + { + foreach (var simulation in _simulations.Values) + { + if (!simulation.IsDirty) continue; + + try + { + var existing = await store.GetSummaryAsync(simulation.WorldId, cancellationToken).ConfigureAwait(false); + if (existing is null || existing.Status != WorldStatus.Ready) + { + simulation.ClearDirty(); + continue; + } + + var updated = simulation.ApplyTo(existing); + await store.SaveSummaryAsync(updated, cancellationToken).ConfigureAwait(false); + simulation.ClearDirty(); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not persist clock for world {Id}", simulation.WorldId); + } + } + } + finally + { + _persistGate.Release(); + } + } +} diff --git a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs index f090efa..0f610ba 100644 --- a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs +++ b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs @@ -56,6 +56,34 @@ public sealed record WorldSummaryDto public required DateTimeOffset CreatedAt { get; init; } public WorldStatsDto? Stats { get; init; } + + /// In-world calendar and playback controls. Present once the world exists; frozen until Ready. + public WorldClockDto? Clock { get; init; } + + /// + /// Wall-clock moment of the last simulation tick. Persisted in state.json for catch-up after + /// restart; stripped from API responses (clients see live only). + /// + public DateTimeOffset? LastTickedAt { get; init; } +} + +/// Live game calendar for a world. Game time is naive local calendar time, not UTC. +public sealed record WorldClockDto +{ + public required DateTime GameTime { get; init; } + + /// Playback multiplier in 1..4. At x1, five game minutes pass per real second. + public required int TimeScale { get; init; } + + public required bool Paused { get; init; } +} + +/// Request body for PATCH /api/worlds/{id}/clock. Omitted fields keep their current value. +public sealed record UpdateClockRequest +{ + public bool? Paused { get; init; } + + public int? TimeScale { get; init; } } /// Everything the client needs to set up its camera and decide which chunks to fetch. diff --git a/src/TheLivingWorld.Core/Ecs/Components.cs b/src/TheLivingWorld.Core/Ecs/Components.cs index d46ec2e..dc618c8 100644 --- a/src/TheLivingWorld.Core/Ecs/Components.cs +++ b/src/TheLivingWorld.Core/Ecs/Components.cs @@ -30,3 +30,9 @@ public record struct Water(WaterKind Kind, float WidthMeters); /// The name tag, or null. Always present so every feature archetype stays uniform. public record struct DisplayName(string? Value); + +/// +/// Singleton simulation clock for a live world. are of a +/// naive (unspecified) calendar — local morning in the town, not UTC. +/// +public record struct GameClock(long Ticks, int TimeScale, bool Paused); diff --git a/src/TheLivingWorld.Core/Simulation/ClockSystem.cs b/src/TheLivingWorld.Core/Simulation/ClockSystem.cs new file mode 100644 index 0000000..66cafaa --- /dev/null +++ b/src/TheLivingWorld.Core/Simulation/ClockSystem.cs @@ -0,0 +1,22 @@ +using Arch.Core; +using TheLivingWorld.Core.Ecs; + +namespace TheLivingWorld.Core.Simulation; + +/// Advances every entity by wall-clock elapsed time. +public sealed class ClockSystem +{ + private static readonly QueryDescription Clocks = new QueryDescription().WithAll(); + + public void Execute(World ecs, TimeSpan realElapsed) + { + if (realElapsed <= TimeSpan.Zero) return; + + ecs.Query(in Clocks, (ref GameClock clock) => + { + var current = new DateTime(clock.Ticks, DateTimeKind.Unspecified); + var next = GameTime.Advance(current, realElapsed, clock.TimeScale, clock.Paused); + clock.Ticks = next.Ticks; + }); + } +} diff --git a/src/TheLivingWorld.Core/Simulation/GameTime.cs b/src/TheLivingWorld.Core/Simulation/GameTime.cs new file mode 100644 index 0000000..e25816c --- /dev/null +++ b/src/TheLivingWorld.Core/Simulation/GameTime.cs @@ -0,0 +1,31 @@ +namespace TheLivingWorld.Core.Simulation; + +/// Pure helpers for the in-world calendar. One real second at x1 advances five game minutes. +public static class GameTime +{ + public static readonly DateTime DefaultStart = new(2012, 4, 12, 6, 0, 0, DateTimeKind.Unspecified); + + public const int GameMinutesPerRealSecond = 5; + + public const int MinTimeScale = 1; + + public const int MaxTimeScale = 4; + + /// + /// Advances a naive game calendar by wall-clock elapsed time. Returns unchanged + /// when paused or when elapsed is non-positive. + /// + public static DateTime Advance(DateTime current, TimeSpan realElapsed, int timeScale, bool paused) + { + if (paused || realElapsed <= TimeSpan.Zero) return current; + + 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); + return current.AddTicks(gameTicks); + } + + public static bool IsValidTimeScale(int timeScale) => + timeScale is >= MinTimeScale and <= MaxTimeScale; +} diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html index 3b84e05..71e0c81 100644 --- a/src/TheLivingWorld.Web/index.html +++ b/src/TheLivingWorld.Web/index.html @@ -85,6 +85,22 @@ ← + diff --git a/src/TheLivingWorld.Web/src/api/client.ts b/src/TheLivingWorld.Web/src/api/client.ts index 2058dc0..6630a29 100644 --- a/src/TheLivingWorld.Web/src/api/client.ts +++ b/src/TheLivingWorld.Web/src/api/client.ts @@ -1,4 +1,12 @@ -import type { CreateWorldRequest, MapChunk, WorldList, WorldMap, WorldSummary } from './types'; +import type { + CreateWorldRequest, + MapChunk, + UpdateClockRequest, + WorldClock, + WorldList, + WorldMap, + WorldSummary, +} from './types'; const BASE = '/api/worlds'; @@ -47,6 +55,13 @@ export const api = { getChunk: (id: string, x: number, y: number, signal?: AbortSignal) => request(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined), + updateClock: (id: string, body: UpdateClockRequest) => + request(`${BASE}/${id}/clock`, { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }), + deleteWorld: async (id: string): Promise => { const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error(await describeFailure(response)); diff --git a/src/TheLivingWorld.Web/src/api/types.ts b/src/TheLivingWorld.Web/src/api/types.ts index 5cd5b4e..997a5d4 100644 --- a/src/TheLivingWorld.Web/src/api/types.ts +++ b/src/TheLivingWorld.Web/src/api/types.ts @@ -21,6 +21,19 @@ export interface WorldSummary { error?: string; createdAt: string; stats?: WorldStats; + clock?: WorldClock; +} + +/** In-world calendar. gameTime is a naive local datetime string (no Z). */ +export interface WorldClock { + gameTime: string; + timeScale: number; + paused: boolean; +} + +export interface UpdateClockRequest { + paused?: boolean; + timeScale?: number; } /** Response body for GET /api/worlds. */ diff --git a/src/TheLivingWorld.Web/src/main.ts b/src/TheLivingWorld.Web/src/main.ts index 0c42686..0fde483 100644 --- a/src/TheLivingWorld.Web/src/main.ts +++ b/src/TheLivingWorld.Web/src/main.ts @@ -1,12 +1,16 @@ import './styles.css'; import { api, waitForWorld } from './api/client'; -import type { WorldSummary } from './api/types'; +import type { WorldClock, WorldSummary } from './api/types'; import { MapView, type MapStatus } from './map/mapView'; import { THEMES, type ThemeName } from './map/theme'; import { formatCoordinates, parseCoordinates } from './ui/coordinates'; +import { formatGameTime, formatGameTimeRaw, interpolateGameTime } from './ui/gameTime'; const LAST_WORLD_KEY = 'the-living-world:last-world'; const THEME_KEY = 'the-living-world:theme'; +const MENU_POLL_MS = 2000; +const GAME_POLL_MS = 1000; +const CLOCK_PAINT_MS = 250; const elements = { menu: required('menu'), @@ -31,6 +35,10 @@ const elements = { back: required('back-button'), menuThemeToggle: required('menu-theme-toggle'), gameThemeToggle: required('game-theme-toggle'), + simControls: required('sim-controls'), + gameClock: required('game-clock'), + playPause: required('play-pause'), + speedButtons: [...document.querySelectorAll('.speed-button')], }; const view = new MapView(); @@ -41,6 +49,18 @@ let mapReady = false; let generating = false; let continueWorldId: string | null = null; +/** Latest list from the API, used to refresh clock labels between polls. */ +let listedWorlds: WorldSummary[] = []; +let menuSnapshotAt = 0; +let menuPollTimer: number | null = null; +let menuPaintTimer: number | null = null; + +let gameClock: WorldClock | null = null; +let gameSnapshotAt = 0; +let gamePollTimer: number | null = null; +let gamePaintTimer: number | null = null; +let clockUpdating = false; + function required(id: string): T { const element = document.getElementById(id); if (!element) throw new Error(`Missing element #${id}`); @@ -55,11 +75,14 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v function showMenu(): void { elements.menu.hidden = false; elements.game.hidden = true; + stopGameClockLoop(); + startMenuClockLoop(); } function showGame(): void { elements.menu.hidden = true; elements.game.hidden = false; + stopMenuClockLoop(); } function renderHud(status: MapStatus): void { @@ -106,11 +129,26 @@ function sortWorlds(worlds: WorldSummary[]): WorldSummary[] { }); } +function displayClock(clock: WorldClock | undefined, snapshotAt: number): string | null { + if (!clock) return null; + const date = interpolateGameTime(clock, performance.now() - snapshotAt); + return date ? formatGameTime(date) : formatGameTimeRaw(clock.gameTime); +} + function updateContinue(worlds: WorldSummary[]): void { const last = worlds.find((world) => world.id === lastWorldId() && world.status === 'ready'); continueWorldId = last?.id ?? null; elements.continue.hidden = last === undefined; - elements.continueName.textContent = last?.name ?? ''; + + if (!last) { + elements.continueName.textContent = ''; + return; + } + + const clockLabel = displayClock(last.clock, menuSnapshotAt); + elements.continueName.textContent = clockLabel + ? `${last.name} · ${clockLabel}` + : last.name; } async function refreshWorldList(): Promise { @@ -119,6 +157,9 @@ async function refreshWorldList(): Promise { worldCount = list.worlds.length; elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`; + listedWorlds = list.worlds; + menuSnapshotAt = performance.now(); + const worlds = sortWorlds(list.worlds); elements.worldsEmpty.hidden = worlds.length > 0; elements.worldList.replaceChildren(...worlds.map(renderWorldItem)); @@ -127,6 +168,43 @@ async function refreshWorldList(): Promise { return list.worlds; } +function paintMenuClocks(): void { + if (elements.menu.hidden) return; + + for (const item of elements.worldList.querySelectorAll('.world')) { + const id = item.dataset.id; + if (!id) continue; + const world = listedWorlds.find((entry) => entry.id === id); + if (!world) continue; + + const detail = item.querySelector('.world__detail'); + if (detail) detail.textContent = describeWorld(world, menuSnapshotAt); + } + + updateContinue(sortWorlds(listedWorlds)); +} + +function startMenuClockLoop(): void { + stopMenuClockLoop(); + menuPollTimer = window.setInterval(() => { + void refreshWorldList().catch(() => { + // Keep the last known list if a poll fails. + }); + }, MENU_POLL_MS); + menuPaintTimer = window.setInterval(paintMenuClocks, CLOCK_PAINT_MS); +} + +function stopMenuClockLoop(): void { + if (menuPollTimer !== null) { + window.clearInterval(menuPollTimer); + menuPollTimer = null; + } + if (menuPaintTimer !== null) { + window.clearInterval(menuPaintTimer); + menuPaintTimer = null; + } +} + function worldBadge(world: WorldSummary): string | null { if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating'; if (world.status === 'failed') return 'Failed'; @@ -138,6 +216,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement { const item = document.createElement('li'); item.className = 'world'; item.dataset.status = world.status; + item.dataset.id = world.id; if (world.id === lastWorldId() && world.status === 'ready') { item.dataset.last = 'true'; } @@ -168,7 +247,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement { const detail = document.createElement('span'); detail.className = 'world__detail'; - detail.textContent = describeWorld(world); + detail.textContent = describeWorld(world, menuSnapshotAt); open.append(nameRow, detail); open.addEventListener('click', () => { @@ -189,14 +268,97 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement { return item; } -function describeWorld(world: WorldSummary): string { +function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string { if (world.status === 'failed') return world.error ?? 'Generation failed'; if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4); const size = `${(world.sizeMeters / 1000).toFixed(0)} km`; - if (!world.stats) return size; + const clockLabel = displayClock(world.clock, snapshotAt); + const stats = world.stats + ? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads` + : null; - return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`; + if (clockLabel && stats) return `${clockLabel} · ${size} · ${stats}`; + if (clockLabel) return `${clockLabel} · ${size}`; + if (stats) return `${size} · ${stats}`; + return size; +} + +function applyClockToControls(clock: WorldClock): void { + gameClock = clock; + gameSnapshotAt = performance.now(); + elements.simControls.hidden = false; + paintGameClock(); + + elements.playPause.textContent = clock.paused ? '▶' : '⏸'; + elements.playPause.title = clock.paused ? 'Play' : 'Pause'; + elements.playPause.setAttribute('aria-label', clock.paused ? 'Play' : 'Pause'); + + for (const button of elements.speedButtons) { + const scale = Number(button.dataset.scale); + button.setAttribute('aria-pressed', scale === clock.timeScale ? 'true' : 'false'); + } +} + +function paintGameClock(): void { + if (!gameClock) { + elements.gameClock.textContent = ''; + return; + } + + const date = interpolateGameTime(gameClock, performance.now() - gameSnapshotAt); + elements.gameClock.textContent = date + ? formatGameTime(date) + : formatGameTimeRaw(gameClock.gameTime); +} + +function startGameClockLoop(worldId: string): void { + stopGameClockLoop(); + + gamePollTimer = window.setInterval(() => { + void pollGameClock(worldId); + }, GAME_POLL_MS); + gamePaintTimer = window.setInterval(paintGameClock, CLOCK_PAINT_MS); +} + +function stopGameClockLoop(): void { + if (gamePollTimer !== null) { + window.clearInterval(gamePollTimer); + gamePollTimer = null; + } + if (gamePaintTimer !== null) { + window.clearInterval(gamePaintTimer); + gamePaintTimer = null; + } + gameClock = null; + elements.simControls.hidden = true; + elements.gameClock.textContent = ''; +} + +async function pollGameClock(worldId: string): Promise { + if (activeWorldId !== worldId || clockUpdating) return; + + try { + const summary = await api.getWorld(worldId); + if (activeWorldId !== worldId || !summary.clock) return; + applyClockToControls(summary.clock); + } catch { + // Keep interpolating from the last good snapshot. + } +} + +async function patchClock(body: { paused?: boolean; timeScale?: number }): Promise { + if (!activeWorldId || clockUpdating) return; + + clockUpdating = true; + try { + const clock = await api.updateClock(activeWorldId, body); + applyClockToControls(clock); + } catch (error) { + setStatus(`Could not update clock: ${message(error)}`, 'error'); + } finally { + clockUpdating = false; + } } async function ensureMap(): Promise { @@ -214,7 +376,7 @@ async function openWorld(id: string): Promise { // leaves the canvas stuck as a thin strip. showGame(); await ensureMap(); - const map = await api.getMap(id); + const [map, summary] = await Promise.all([api.getMap(id), api.getWorld(id)]); activeWorldId = id; localStorage.setItem(LAST_WORLD_KEY, id); @@ -223,6 +385,10 @@ async function openWorld(id: string): Promise { elements.worldTitle.textContent = map.name; elements.hud.textContent = ''; setStatus(''); + + if (summary.clock) applyClockToControls(summary.clock); + startGameClockLoop(id); + await refreshWorldList(); } catch (error) { showMenu(); @@ -232,6 +398,7 @@ async function openWorld(id: string): Promise { async function returnToMenu(): Promise { activeWorldId = null; + stopGameClockLoop(); if (mapReady) view.clear(); elements.hud.textContent = ''; elements.worldTitle.textContent = ''; @@ -252,6 +419,7 @@ async function deleteWorld(world: WorldSummary): Promise { if (activeWorldId === world.id) { activeWorldId = null; + stopGameClockLoop(); localStorage.removeItem(LAST_WORLD_KEY); if (mapReady) view.clear(); elements.hud.textContent = ''; @@ -376,6 +544,17 @@ async function start(): Promise { }); elements.menuThemeToggle.addEventListener('click', toggleTheme); elements.gameThemeToggle.addEventListener('click', toggleTheme); + elements.playPause.addEventListener('click', () => { + if (!gameClock) return; + void patchClock({ paused: !gameClock.paused }); + }); + for (const button of elements.speedButtons) { + button.addEventListener('click', () => { + const scale = Number(button.dataset.scale); + if (!Number.isFinite(scale) || scale === gameClock?.timeScale) return; + void patchClock({ timeScale: scale }); + }); + } applyTheme(readStoredTheme()); showMenu(); diff --git a/src/TheLivingWorld.Web/src/styles.css b/src/TheLivingWorld.Web/src/styles.css index e6db070..2de7bf1 100644 --- a/src/TheLivingWorld.Web/src/styles.css +++ b/src/TheLivingWorld.Web/src/styles.css @@ -143,6 +143,62 @@ body { backdrop-filter: blur(6px); } +.sim-controls { + display: flex; + flex: none; + align-items: center; + gap: 6px; + padding: 4px 6px 4px 10px; + background: var(--panel-bg); + border: 1px solid var(--panel-border); + border-radius: 8px; + backdrop-filter: blur(6px); +} + +.sim-controls__clock { + min-width: 11.5rem; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--text); + white-space: nowrap; +} + +.sim-controls__speeds { + display: flex; + gap: 2px; +} + +.speed-button { + min-width: 32px; + height: 26px; + padding: 0 6px; + font: inherit; + font-size: 11px; + font-weight: 700; + color: var(--text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + cursor: pointer; +} + +.speed-button:hover { + color: var(--text); + background: var(--surface-hover); +} + +.speed-button[aria-pressed='true'] { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} + +.speed-button[aria-pressed='true']:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + .icon-button { flex: none; width: 30px; diff --git a/src/TheLivingWorld.Web/src/ui/gameTime.test.ts b/src/TheLivingWorld.Web/src/ui/gameTime.test.ts new file mode 100644 index 0000000..27dc64f --- /dev/null +++ b/src/TheLivingWorld.Web/src/ui/gameTime.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { + formatGameTime, + formatGameTimeRaw, + GAME_MINUTES_PER_REAL_SECOND, + interpolateGameTime, + parseGameTime, +} from './gameTime'; + +describe('parseGameTime', () => { + it('reads a naive ISO local datetime', () => { + const date = parseGameTime('2012-04-12T06:00:00'); + expect(date).not.toBeNull(); + expect(date!.getFullYear()).toBe(2012); + expect(date!.getMonth()).toBe(3); + expect(date!.getDate()).toBe(12); + expect(date!.getHours()).toBe(6); + expect(date!.getMinutes()).toBe(0); + }); + + it('rejects garbage', () => { + expect(parseGameTime('')).toBeNull(); + expect(parseGameTime('not-a-date')).toBeNull(); + }); +}); + +describe('formatGameTime', () => { + it('formats without seconds', () => { + const date = new Date(2012, 3, 12, 6, 0, 0); + expect(formatGameTime(date)).toBe('12 April 2012 · 06:00'); + }); + + it('formats a raw API string', () => { + expect(formatGameTimeRaw('2012-04-12T06:00:00')).toBe('12 April 2012 · 06:00'); + }); +}); + +describe('interpolateGameTime', () => { + it('holds still while paused', () => { + const date = interpolateGameTime( + { gameTime: '2012-04-12T06:00:00', timeScale: 4, paused: true }, + 10_000, + ); + expect(date).not.toBeNull(); + expect(formatGameTime(date!)).toBe('12 April 2012 · 06:00'); + }); + + it('advances five game minutes per real second at x1', () => { + const date = interpolateGameTime( + { gameTime: '2012-04-12T06:00:00', timeScale: 1, paused: false }, + 1000, + ); + expect(date).not.toBeNull(); + expect(formatGameTime(date!)).toBe('12 April 2012 · 06:05'); + expect(GAME_MINUTES_PER_REAL_SECOND).toBe(5); + }); + + it('scales with timeScale', () => { + const date = interpolateGameTime( + { gameTime: '2012-04-12T06:00:00', timeScale: 2, paused: false }, + 1000, + ); + expect(date).not.toBeNull(); + expect(formatGameTime(date!)).toBe('12 April 2012 · 06:10'); + }); +}); diff --git a/src/TheLivingWorld.Web/src/ui/gameTime.ts b/src/TheLivingWorld.Web/src/ui/gameTime.ts new file mode 100644 index 0000000..de659bf --- /dev/null +++ b/src/TheLivingWorld.Web/src/ui/gameTime.ts @@ -0,0 +1,70 @@ +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; + +const MONTHS = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +] as const; + +/** + * Parses a naive game datetime from the API (`2012-04-12T06:00:00` or with fractional seconds). + * Treats the value as a local calendar instant, not UTC. + */ +export function parseGameTime(raw: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(?:\.\d+)?/.exec(raw.trim()); + if (!match) return null; + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + + const date = new Date(year, month - 1, day, hour, minute, second); + if ( + date.getFullYear() !== year + || date.getMonth() !== month - 1 + || date.getDate() !== day + || date.getHours() !== hour + || date.getMinutes() !== minute + ) { + return null; + } + + return date; +} + +/** Formats as `12 April 2012 · 06:00` (no seconds — they are meaningless at 5 min/s). */ +export function formatGameTime(date: Date): string { + const day = date.getDate(); + const month = MONTHS[date.getMonth()]!; + const year = date.getFullYear(); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${day} ${month} ${year} · ${hours}:${minutes}`; +} + +export function formatGameTimeRaw(raw: string): string { + const date = parseGameTime(raw); + return date ? formatGameTime(date) : raw; +} + +/** + * Interpolates displayed game time between server polls so the clock does not jump. + * `elapsedRealMs` is wall time since the snapshot was received. + */ +export function interpolateGameTime( + clock: WorldClock, + elapsedRealMs: number, +): Date | null { + const base = parseGameTime(clock.gameTime); + if (!base) return null; + if (clock.paused || elapsedRealMs <= 0) return base; + + const scale = Number.isFinite(clock.timeScale) ? clock.timeScale : 1; + const gameMs = (elapsedRealMs / 1000) * GAME_MINUTES_PER_REAL_SECOND * scale * 60_000; + return new Date(base.getTime() + gameMs); +} diff --git a/tests/TheLivingWorld.Tests/GameTimeTests.cs b/tests/TheLivingWorld.Tests/GameTimeTests.cs new file mode 100644 index 0000000..e4c68ee --- /dev/null +++ b/tests/TheLivingWorld.Tests/GameTimeTests.cs @@ -0,0 +1,61 @@ +using TheLivingWorld.Core.Simulation; + +namespace TheLivingWorld.Tests; + +public sealed class GameTimeTests +{ + [Fact] + public void Advance_at_x1_adds_five_game_minutes_per_real_second() + { + var start = GameTime.DefaultStart; + var next = GameTime.Advance(start, TimeSpan.FromSeconds(1), timeScale: 1, paused: false); + + Assert.Equal(start.AddMinutes(5), next); + } + + [Fact] + public void Advance_scales_with_time_scale() + { + var start = GameTime.DefaultStart; + + Assert.Equal(start.AddMinutes(10), GameTime.Advance(start, TimeSpan.FromSeconds(1), 2, false)); + Assert.Equal(start.AddMinutes(20), GameTime.Advance(start, TimeSpan.FromSeconds(1), 4, false)); + } + + [Fact] + public void Advance_does_nothing_when_paused_or_non_positive() + { + var start = GameTime.DefaultStart; + + Assert.Equal(start, GameTime.Advance(start, TimeSpan.FromSeconds(10), 4, paused: true)); + Assert.Equal(start, GameTime.Advance(start, TimeSpan.Zero, 1, paused: false)); + Assert.Equal(start, GameTime.Advance(start, TimeSpan.FromSeconds(-1), 1, paused: false)); + } + + [Fact] + public void Advance_handles_a_large_wall_clock_gap() + { + var start = GameTime.DefaultStart; + // One real hour at x1 → 5 * 3600 = 18_000 game minutes = 12.5 game days. + var next = GameTime.Advance(start, TimeSpan.FromHours(1), timeScale: 1, paused: false); + + Assert.Equal(start.AddMinutes(5 * 3600), next); + Assert.Equal(new DateTime(2012, 4, 24, 18, 0, 0, DateTimeKind.Unspecified), next); + } + + [Fact] + public void DefaultStart_is_morning_of_12_April_2012() + { + Assert.Equal(new DateTime(2012, 4, 12, 6, 0, 0, DateTimeKind.Unspecified), GameTime.DefaultStart); + } + + [Theory] + [InlineData(1, true)] + [InlineData(4, true)] + [InlineData(0, false)] + [InlineData(5, false)] + public void IsValidTimeScale_accepts_1_through_4(int scale, bool expected) + { + Assert.Equal(expected, GameTime.IsValidTimeScale(scale)); + } +} diff --git a/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs b/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs index 8c9762f..1a94591 100644 --- a/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs +++ b/tests/TheLivingWorld.Tests/WorldGenerationServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using TheLivingWorld.Api.Generation; +using TheLivingWorld.Api.Simulation; using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Export; @@ -66,6 +67,7 @@ public sealed class WorldGenerationServiceTests : IDisposable generator, _store, new ChunkExporter(), + new WorldSimulationHost(_store, NullLogger.Instance), Options.Create(new WorldStorageOptions { RootDirectory = _root, diff --git a/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs b/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs new file mode 100644 index 0000000..941f673 --- /dev/null +++ b/tests/TheLivingWorld.Tests/WorldSimulationHostTests.cs @@ -0,0 +1,115 @@ +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, NullLogger.Instance); + } + + [Fact] + public async Task Attach_hydrates_legacy_worlds_without_catch_up_from_created_at() + { + var legacy = new WorldSummaryDto + { + Id = "legacy-11111111", + Name = "Legacy", + Latitude = 31.9, + Longitude = -100.5, + SizeMeters = 10_000, + Status = WorldStatus.Ready, + CreatedAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(30), + }; + await _store.SaveSummaryAsync(legacy); + + // Mimic host startup hydration for worlds that lack a clock. + var seeded = legacy with + { + Clock = WorldSimulation.DefaultClock(), + LastTickedAt = DateTimeOffset.UtcNow, + }; + await _store.SaveSummaryAsync(seeded); + _host.Attach(seeded with { LastTickedAt = null }); // Attach without prior tick → no catch-up + + // 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); + Assert.NotNull(clock); + Assert.Equal(GameTime.DefaultStart, clock.GameTime); + } + + [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, + }; + 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)); + } + + [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, + }; + _host.Attach(summary); + + var overlaid = _host.Overlay(summary); + Assert.NotNull(overlaid.Clock); + Assert.Null(overlaid.LastTickedAt); + } + + public void Dispose() + { + foreach (var id in new[] { "legacy-11111111", "ready-22222222", "ready-33333333" }) + _host.Detach(id); + + if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); + GC.SuppressFinalize(this); + } +} diff --git a/tests/TheLivingWorld.Tests/WorldSimulationTests.cs b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs new file mode 100644 index 0000000..c472acc --- /dev/null +++ b/tests/TheLivingWorld.Tests/WorldSimulationTests.cs @@ -0,0 +1,105 @@ +using TheLivingWorld.Api.Simulation; +using TheLivingWorld.Core.Contracts; +using TheLivingWorld.Core.Simulation; + +namespace TheLivingWorld.Tests; + +public sealed class WorldSimulationTests +{ + [Fact] + public void Create_starts_at_default_morning_and_ticks_at_x1() + { + using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); + + var before = simulation.SnapshotClock(); + Assert.Equal(GameTime.DefaultStart, before.GameTime); + Assert.Equal(1, before.TimeScale); + Assert.False(before.Paused); + + simulation.Tick(TimeSpan.FromSeconds(2)); + + var after = simulation.SnapshotClock(); + Assert.Equal(GameTime.DefaultStart.AddMinutes(10), after.GameTime); + Assert.True(simulation.IsDirty); + } + + [Fact] + public void Tick_does_not_advance_while_paused() + { + var summary = ReadySummary() with + { + Clock = WorldSimulation.DefaultClock() with { Paused = true }, + }; + using var simulation = WorldSimulation.Create(summary, catchUp: false); + + simulation.Tick(TimeSpan.FromSeconds(5)); + + Assert.Equal(GameTime.DefaultStart, simulation.SnapshotClock().GameTime); + Assert.False(simulation.IsDirty); + } + + [Fact] + public void Update_bakes_elapsed_time_before_changing_scale() + { + using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); + + simulation.Tick(TimeSpan.FromSeconds(1)); + var clock = simulation.Update(new UpdateClockRequest { TimeScale = 2 }); + + Assert.Equal(2, clock.TimeScale); + // Tick advanced 5 game minutes; Update may bake a few extra wall-clock milliseconds. + Assert.InRange( + clock.GameTime, + GameTime.DefaultStart.AddMinutes(5), + GameTime.DefaultStart.AddMinutes(5).AddSeconds(30)); + Assert.True(simulation.IsDirty); + } + + [Fact] + public void Update_rejects_invalid_time_scale() + { + using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); + + Assert.Throws(() => + simulation.Update(new UpdateClockRequest { TimeScale = 5 })); + } + + [Fact] + public void Catch_up_advances_from_last_ticked_at() + { + var lastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromSeconds(3); + var summary = ReadySummary() with { LastTickedAt = lastTickedAt }; + + using var simulation = WorldSimulation.Create(summary, catchUp: true); + + // ~3 real seconds at x1 → ~15 game minutes (allow a little wall-clock drift). + var actual = simulation.SnapshotClock().GameTime; + Assert.InRange( + actual, + GameTime.DefaultStart.AddMinutes(14), + GameTime.DefaultStart.AddMinutes(17)); + } + + [Fact] + public void OverlayForApi_strips_last_ticked_at() + { + using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); + var overlaid = simulation.OverlayForApi(ReadySummary()); + + Assert.NotNull(overlaid.Clock); + Assert.Null(overlaid.LastTickedAt); + } + + private static WorldSummaryDto ReadySummary() => new() + { + Id = "town-aaaaaaaa", + Name = "Town", + Latitude = 31.9, + Longitude = -100.5, + SizeMeters = 10_000, + Status = WorldStatus.Ready, + CreatedAt = DateTimeOffset.UtcNow, + Clock = WorldSimulation.DefaultClock(), + LastTickedAt = DateTimeOffset.UtcNow, + }; +}