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:
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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` 1–4). 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 _);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user