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.

This commit is contained in:
Leonid Pershin
2026-08-16 19:31:12 +03:00
parent b75844d11a
commit 3b7fca1495
22 changed files with 1290 additions and 14 deletions
+3 -1
View File
@@ -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` | | Aspire host | `src/TheLivingWorld.AppHost` |
| Tests | `tests/TheLivingWorld.Tests` (xUnit), Vitest under the web project | | 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`. Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
+1
View File
@@ -108,6 +108,7 @@ them without reworking the data model.
| `GET /api/worlds/{id}` | Status of one world | | `GET /api/worlds/{id}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index | | `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry | | `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry |
| `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 14). Body: `{ paused?, timeScale? }` |
| `DELETE /api/worlds/{id}` | Remove a world and its chunks | | `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 Generation takes tens of seconds — mostly waiting on Overpass — so `POST` returns straight away and the client
@@ -1,6 +1,8 @@
using TheLivingWorld.Api.Generation; using TheLivingWorld.Api.Generation;
using TheLivingWorld.Api.Simulation;
using TheLivingWorld.Api.Storage; using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Api.Endpoints; namespace TheLivingWorld.Api.Endpoints;
@@ -15,6 +17,7 @@ public static class WorldEndpoints
worlds.MapGet("/{id}", GetWorld); worlds.MapGet("/{id}", GetWorld);
worlds.MapGet("/{id}/map", GetMap); worlds.MapGet("/{id}/map", GetMap);
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk); worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
worlds.MapPatch("/{id}/clock", UpdateClock);
worlds.MapDelete("/{id}", DeleteWorld); worlds.MapDelete("/{id}", DeleteWorld);
return app; return app;
@@ -23,13 +26,19 @@ public static class WorldEndpoints
private static async Task<IResult> ListWorlds( private static async Task<IResult> ListWorlds(
WorldStore store, WorldStore store,
WorldGenerationService generation, WorldGenerationService generation,
WorldSimulationHost simulation,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var stored = await store.ListAsync(cancellationToken); var stored = await store.ListAsync(cancellationToken);
// A world being generated right now has a fresher status in memory than on disk. // 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 var merged = stored
.Select(summary => generation.GetInFlight(summary.Id) ?? summary) .Select(summary =>
{
var live = generation.GetInFlight(summary.Id) ?? summary;
return simulation.Overlay(live);
})
.ToArray(); .ToArray();
return Results.Ok(new WorldListDto return Results.Ok(new WorldListDto
@@ -47,7 +56,7 @@ public static class WorldEndpoints
try try
{ {
var summary = await generation.StartAsync(request, cancellationToken); 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) catch (ArgumentException ex)
{ {
@@ -71,14 +80,16 @@ public static class WorldEndpoints
string id, string id,
WorldStore store, WorldStore store,
WorldGenerationService generation, WorldGenerationService generation,
WorldSimulationHost simulation,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!WorldStore.IsValidId(id)) return Results.NotFound(); 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); 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<IResult> GetMap(string id, WorldStore store, CancellationToken cancellationToken) private static async Task<IResult> GetMap(string id, WorldStore store, CancellationToken cancellationToken)
@@ -101,9 +112,62 @@ public static class WorldEndpoints
return Results.Stream(stream, "application/json"); return Results.Stream(stream, "application/json");
} }
private static IResult DeleteWorld(string id, WorldStore store) private static async Task<IResult> UpdateClock(
string id,
UpdateClockRequest request,
WorldStore store,
WorldSimulationHost simulation,
CancellationToken cancellationToken)
{ {
if (!WorldStore.IsValidId(id)) return Results.NotFound(); if (!WorldStore.IsValidId(id)) return Results.NotFound();
if (request.TimeScale is { } scale && !GameTime.IsValidTimeScale(scale))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["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<string, string[]>
{
["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<string, string[]>
{
["timeScale"] = [ex.Message],
});
}
catch (InvalidOperationException ex)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["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(); return store.Delete(id) ? Results.NoContent() : Results.NotFound();
} }
} }
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TheLivingWorld.Api.Simulation;
using TheLivingWorld.Api.Storage; using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Export;
@@ -20,6 +21,7 @@ public sealed class WorldGenerationService(
OsmWorldGenerator generator, OsmWorldGenerator generator,
WorldStore store, WorldStore store,
ChunkExporter exporter, ChunkExporter exporter,
WorldSimulationHost simulation,
IOptions<WorldStorageOptions> storageOptions, IOptions<WorldStorageOptions> storageOptions,
IHostApplicationLifetime lifetime, IHostApplicationLifetime lifetime,
ILogger<WorldGenerationService> logger) : IDisposable ILogger<WorldGenerationService> logger) : IDisposable
@@ -59,6 +61,7 @@ public sealed class WorldGenerationService(
Status = WorldStatus.Pending, Status = WorldStatus.Pending,
Stage = "Queued", Stage = "Queued",
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
Clock = WorldSimulation.DefaultClock(),
}; };
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -146,9 +149,12 @@ public sealed class WorldGenerationService(
Water = stats.Water, Water = stats.Water,
Vertices = stats.Vertices, Vertices = stats.Vertices,
}, },
Clock = summary.Clock ?? WorldSimulation.DefaultClock(),
LastTickedAt = DateTimeOffset.UtcNow,
}; };
await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false); await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false);
simulation.Attach(ready);
Publish(ready); Publish(ready);
_inFlight.TryRemove(summary.Id, out _); _inFlight.TryRemove(summary.Id, out _);
} }
+3
View File
@@ -2,6 +2,7 @@ using System.IO.Compression;
using Microsoft.AspNetCore.ResponseCompression; using Microsoft.AspNetCore.ResponseCompression;
using TheLivingWorld.Api.Endpoints; using TheLivingWorld.Api.Endpoints;
using TheLivingWorld.Api.Generation; using TheLivingWorld.Api.Generation;
using TheLivingWorld.Api.Simulation;
using TheLivingWorld.Api.Storage; using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Export;
@@ -35,6 +36,8 @@ builder.Services.PostConfigure<OsmOptions>(options =>
builder.Services.AddSingleton<WorldStore>(); builder.Services.AddSingleton<WorldStore>();
builder.Services.AddSingleton<ChunkExporter>(); builder.Services.AddSingleton<ChunkExporter>();
builder.Services.AddSingleton<WorldSimulationHost>();
builder.Services.AddHostedService(provider => provider.GetRequiredService<WorldSimulationHost>());
builder.Services.AddSingleton<WorldGenerationService>(); builder.Services.AddSingleton<WorldGenerationService>();
// Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude. // Geometry payloads are highly repetitive JSON and compress by roughly an order of magnitude.
@@ -0,0 +1,201 @@
using Arch.Core;
using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Api.Simulation;
/// <summary>
/// Lightweight live runtime for one world: an Arch world holding a single <see cref="GameClock"/> entity.
/// Map geometry stays on disk; only the clock (and later sim state) lives here.
/// </summary>
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;
}
}
/// <summary>
/// Builds a simulation from persisted summary state. When <paramref name="catchUp"/> is true and the
/// clock is not paused, advances for the wall-clock gap since <see cref="WorldSummaryDto.LastTickedAt"/>.
/// </summary>
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;
}
}
/// <summary>
/// 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.
/// </summary>
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<GameClock>(_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,
};
}
}
/// <summary>Wire-facing snapshot: live clock, no internal last-tick stamp.</summary>
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<GameClock>(_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);
}
}
}
@@ -0,0 +1,214 @@
using System.Collections.Concurrent;
using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts;
namespace TheLivingWorld.Api.Simulation;
/// <summary>
/// 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.
/// </summary>
public sealed class WorldSimulationHost(
WorldStore store,
ILogger<WorldSimulationHost> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100);
private static readonly TimeSpan PersistInterval = TimeSpan.FromSeconds(5);
private readonly ConcurrentDictionary<string, WorldSimulation> _simulations = new();
private readonly SemaphoreSlim _persistGate = new(1, 1);
/// <summary>Attaches a Ready world if it is not already running. Idempotent.</summary>
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);
});
}
/// <summary>Stops simulation for a world. Safe to call when the world was never attached.</summary>
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<WorldSummaryDto> 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();
}
}
}
@@ -56,6 +56,34 @@ public sealed record WorldSummaryDto
public required DateTimeOffset CreatedAt { get; init; } public required DateTimeOffset CreatedAt { get; init; }
public WorldStatsDto? Stats { get; init; } public WorldStatsDto? Stats { get; init; }
/// <summary>In-world calendar and playback controls. Present once the world exists; frozen until Ready.</summary>
public WorldClockDto? Clock { get; init; }
/// <summary>
/// Wall-clock moment of the last simulation tick. Persisted in <c>state.json</c> for catch-up after
/// restart; stripped from API responses (clients see live <see cref="Clock"/> only).
/// </summary>
public DateTimeOffset? LastTickedAt { get; init; }
}
/// <summary>Live game calendar for a world. Game time is naive local calendar time, not UTC.</summary>
public sealed record WorldClockDto
{
public required DateTime GameTime { get; init; }
/// <summary>Playback multiplier in 1..4. At x1, five game minutes pass per real second.</summary>
public required int TimeScale { get; init; }
public required bool Paused { get; init; }
}
/// <summary>Request body for <c>PATCH /api/worlds/{id}/clock</c>. Omitted fields keep their current value.</summary>
public sealed record UpdateClockRequest
{
public bool? Paused { get; init; }
public int? TimeScale { get; init; }
} }
/// <summary>Everything the client needs to set up its camera and decide which chunks to fetch.</summary> /// <summary>Everything the client needs to set up its camera and decide which chunks to fetch.</summary>
@@ -30,3 +30,9 @@ public record struct Water(WaterKind Kind, float WidthMeters);
/// <summary>The <c>name</c> tag, or null. Always present so every feature archetype stays uniform.</summary> /// <summary>The <c>name</c> tag, or null. Always present so every feature archetype stays uniform.</summary>
public record struct DisplayName(string? Value); public record struct DisplayName(string? Value);
/// <summary>
/// Singleton simulation clock for a live world. <see cref="Ticks"/> are <see cref="DateTime.Ticks"/> of a
/// naive (unspecified) calendar — local morning in the town, not UTC.
/// </summary>
public record struct GameClock(long Ticks, int TimeScale, bool Paused);
@@ -0,0 +1,22 @@
using Arch.Core;
using TheLivingWorld.Core.Ecs;
namespace TheLivingWorld.Core.Simulation;
/// <summary>Advances every <see cref="GameClock"/> entity by wall-clock elapsed time.</summary>
public sealed class ClockSystem
{
private static readonly QueryDescription Clocks = new QueryDescription().WithAll<GameClock>();
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;
});
}
}
@@ -0,0 +1,31 @@
namespace TheLivingWorld.Core.Simulation;
/// <summary>Pure helpers for the in-world calendar. One real second at x1 advances five game minutes.</summary>
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;
/// <summary>
/// Advances a naive game calendar by wall-clock elapsed time. Returns <paramref name="current"/> unchanged
/// when paused or when elapsed is non-positive.
/// </summary>
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;
}
+16
View File
@@ -85,6 +85,22 @@
</button> </button>
<span id="world-title" class="game-bar__title"></span> <span id="world-title" class="game-bar__title"></span>
<div id="sim-controls" class="sim-controls" hidden>
<span id="game-clock" class="sim-controls__clock" aria-live="polite"></span>
<button
id="play-pause"
type="button"
class="icon-button"
title="Pause"
aria-label="Pause"
></button>
<div class="sim-controls__speeds" role="group" aria-label="Simulation speed">
<button type="button" class="speed-button" data-scale="1">x1</button>
<button type="button" class="speed-button" data-scale="2">x2</button>
<button type="button" class="speed-button" data-scale="3">x3</button>
<button type="button" class="speed-button" data-scale="4">x4</button>
</div>
</div>
<button id="game-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme"> <button id="game-theme-toggle" type="button" class="icon-button" title="Switch theme" aria-label="Switch theme">
</button> </button>
+16 -1
View File
@@ -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'; const BASE = '/api/worlds';
@@ -47,6 +55,13 @@ export const api = {
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) => getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined), request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
updateClock: (id: string, body: UpdateClockRequest) =>
request<WorldClock>(`${BASE}/${id}/clock`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
}),
deleteWorld: async (id: string): Promise<void> => { deleteWorld: async (id: string): Promise<void> => {
const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' }); const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
if (!response.ok) throw new Error(await describeFailure(response)); if (!response.ok) throw new Error(await describeFailure(response));
+13
View File
@@ -21,6 +21,19 @@ export interface WorldSummary {
error?: string; error?: string;
createdAt: string; createdAt: string;
stats?: WorldStats; 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. */ /** Response body for GET /api/worlds. */
+186 -7
View File
@@ -1,12 +1,16 @@
import './styles.css'; import './styles.css';
import { api, waitForWorld } from './api/client'; 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 { MapView, type MapStatus } from './map/mapView';
import { THEMES, type ThemeName } from './map/theme'; import { THEMES, type ThemeName } from './map/theme';
import { formatCoordinates, parseCoordinates } from './ui/coordinates'; import { formatCoordinates, parseCoordinates } from './ui/coordinates';
import { formatGameTime, formatGameTimeRaw, interpolateGameTime } from './ui/gameTime';
const LAST_WORLD_KEY = 'the-living-world:last-world'; const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme'; const THEME_KEY = 'the-living-world:theme';
const MENU_POLL_MS = 2000;
const GAME_POLL_MS = 1000;
const CLOCK_PAINT_MS = 250;
const elements = { const elements = {
menu: required<HTMLDivElement>('menu'), menu: required<HTMLDivElement>('menu'),
@@ -31,6 +35,10 @@ const elements = {
back: required<HTMLButtonElement>('back-button'), back: required<HTMLButtonElement>('back-button'),
menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'), menuThemeToggle: required<HTMLButtonElement>('menu-theme-toggle'),
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'), gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
simControls: required<HTMLDivElement>('sim-controls'),
gameClock: required<HTMLElement>('game-clock'),
playPause: required<HTMLButtonElement>('play-pause'),
speedButtons: [...document.querySelectorAll<HTMLButtonElement>('.speed-button')],
}; };
const view = new MapView(); const view = new MapView();
@@ -41,6 +49,18 @@ let mapReady = false;
let generating = false; let generating = false;
let continueWorldId: string | null = null; 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<T extends HTMLElement>(id: string): T { function required<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) throw new Error(`Missing element #${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 { function showMenu(): void {
elements.menu.hidden = false; elements.menu.hidden = false;
elements.game.hidden = true; elements.game.hidden = true;
stopGameClockLoop();
startMenuClockLoop();
} }
function showGame(): void { function showGame(): void {
elements.menu.hidden = true; elements.menu.hidden = true;
elements.game.hidden = false; elements.game.hidden = false;
stopMenuClockLoop();
} }
function renderHud(status: MapStatus): void { 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 { function updateContinue(worlds: WorldSummary[]): void {
const last = worlds.find((world) => world.id === lastWorldId() && world.status === 'ready'); const last = worlds.find((world) => world.id === lastWorldId() && world.status === 'ready');
continueWorldId = last?.id ?? null; continueWorldId = last?.id ?? null;
elements.continue.hidden = last === undefined; 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<WorldSummary[]> { async function refreshWorldList(): Promise<WorldSummary[]> {
@@ -119,6 +157,9 @@ async function refreshWorldList(): Promise<WorldSummary[]> {
worldCount = list.worlds.length; worldCount = list.worlds.length;
elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`; elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`;
listedWorlds = list.worlds;
menuSnapshotAt = performance.now();
const worlds = sortWorlds(list.worlds); const worlds = sortWorlds(list.worlds);
elements.worldsEmpty.hidden = worlds.length > 0; elements.worldsEmpty.hidden = worlds.length > 0;
elements.worldList.replaceChildren(...worlds.map(renderWorldItem)); elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
@@ -127,6 +168,43 @@ async function refreshWorldList(): Promise<WorldSummary[]> {
return list.worlds; return list.worlds;
} }
function paintMenuClocks(): void {
if (elements.menu.hidden) return;
for (const item of elements.worldList.querySelectorAll<HTMLLIElement>('.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 { function worldBadge(world: WorldSummary): string | null {
if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating'; if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating';
if (world.status === 'failed') return 'Failed'; if (world.status === 'failed') return 'Failed';
@@ -138,6 +216,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
const item = document.createElement('li'); const item = document.createElement('li');
item.className = 'world'; item.className = 'world';
item.dataset.status = world.status; item.dataset.status = world.status;
item.dataset.id = world.id;
if (world.id === lastWorldId() && world.status === 'ready') { if (world.id === lastWorldId() && world.status === 'ready') {
item.dataset.last = 'true'; item.dataset.last = 'true';
} }
@@ -168,7 +247,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
const detail = document.createElement('span'); const detail = document.createElement('span');
detail.className = 'world__detail'; detail.className = 'world__detail';
detail.textContent = describeWorld(world); detail.textContent = describeWorld(world, menuSnapshotAt);
open.append(nameRow, detail); open.append(nameRow, detail);
open.addEventListener('click', () => { open.addEventListener('click', () => {
@@ -189,14 +268,97 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
return item; 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 === 'failed') return world.error ?? 'Generation failed';
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4); if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`; 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<void> {
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<void> {
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<void> { async function ensureMap(): Promise<void> {
@@ -214,7 +376,7 @@ async function openWorld(id: string): Promise<void> {
// leaves the canvas stuck as a thin strip. // leaves the canvas stuck as a thin strip.
showGame(); showGame();
await ensureMap(); await ensureMap();
const map = await api.getMap(id); const [map, summary] = await Promise.all([api.getMap(id), api.getWorld(id)]);
activeWorldId = id; activeWorldId = id;
localStorage.setItem(LAST_WORLD_KEY, id); localStorage.setItem(LAST_WORLD_KEY, id);
@@ -223,6 +385,10 @@ async function openWorld(id: string): Promise<void> {
elements.worldTitle.textContent = map.name; elements.worldTitle.textContent = map.name;
elements.hud.textContent = ''; elements.hud.textContent = '';
setStatus(''); setStatus('');
if (summary.clock) applyClockToControls(summary.clock);
startGameClockLoop(id);
await refreshWorldList(); await refreshWorldList();
} catch (error) { } catch (error) {
showMenu(); showMenu();
@@ -232,6 +398,7 @@ async function openWorld(id: string): Promise<void> {
async function returnToMenu(): Promise<void> { async function returnToMenu(): Promise<void> {
activeWorldId = null; activeWorldId = null;
stopGameClockLoop();
if (mapReady) view.clear(); if (mapReady) view.clear();
elements.hud.textContent = ''; elements.hud.textContent = '';
elements.worldTitle.textContent = ''; elements.worldTitle.textContent = '';
@@ -252,6 +419,7 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
if (activeWorldId === world.id) { if (activeWorldId === world.id) {
activeWorldId = null; activeWorldId = null;
stopGameClockLoop();
localStorage.removeItem(LAST_WORLD_KEY); localStorage.removeItem(LAST_WORLD_KEY);
if (mapReady) view.clear(); if (mapReady) view.clear();
elements.hud.textContent = ''; elements.hud.textContent = '';
@@ -376,6 +544,17 @@ async function start(): Promise<void> {
}); });
elements.menuThemeToggle.addEventListener('click', toggleTheme); elements.menuThemeToggle.addEventListener('click', toggleTheme);
elements.gameThemeToggle.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()); applyTheme(readStoredTheme());
showMenu(); showMenu();
+56
View File
@@ -143,6 +143,62 @@ body {
backdrop-filter: blur(6px); 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 { .icon-button {
flex: none; flex: none;
width: 30px; width: 30px;
@@ -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');
});
});
+70
View File
@@ -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);
}
@@ -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));
}
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using TheLivingWorld.Api.Generation; using TheLivingWorld.Api.Generation;
using TheLivingWorld.Api.Simulation;
using TheLivingWorld.Api.Storage; using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Export;
@@ -66,6 +67,7 @@ public sealed class WorldGenerationServiceTests : IDisposable
generator, generator,
_store, _store,
new ChunkExporter(), new ChunkExporter(),
new WorldSimulationHost(_store, NullLogger<WorldSimulationHost>.Instance),
Options.Create(new WorldStorageOptions Options.Create(new WorldStorageOptions
{ {
RootDirectory = _root, RootDirectory = _root,
@@ -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<WorldStore>.Instance);
_host = new WorldSimulationHost(_store, NullLogger<WorldSimulationHost>.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);
}
}
@@ -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<ArgumentOutOfRangeException>(() =>
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,
};
}