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:
@@ -1,6 +1,8 @@
|
||||
using TheLivingWorld.Api.Generation;
|
||||
using TheLivingWorld.Api.Simulation;
|
||||
using TheLivingWorld.Api.Storage;
|
||||
using TheLivingWorld.Core.Contracts;
|
||||
using TheLivingWorld.Core.Simulation;
|
||||
|
||||
namespace TheLivingWorld.Api.Endpoints;
|
||||
|
||||
@@ -15,6 +17,7 @@ public static class WorldEndpoints
|
||||
worlds.MapGet("/{id}", GetWorld);
|
||||
worlds.MapGet("/{id}/map", GetMap);
|
||||
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
|
||||
worlds.MapPatch("/{id}/clock", UpdateClock);
|
||||
worlds.MapDelete("/{id}", DeleteWorld);
|
||||
|
||||
return app;
|
||||
@@ -23,13 +26,19 @@ public static class WorldEndpoints
|
||||
private static async Task<IResult> ListWorlds(
|
||||
WorldStore store,
|
||||
WorldGenerationService generation,
|
||||
WorldSimulationHost simulation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var stored = await store.ListAsync(cancellationToken);
|
||||
|
||||
// A world being generated right now has a fresher status in memory than on disk.
|
||||
// Ready worlds overlay the live clock from the simulation host.
|
||||
var merged = stored
|
||||
.Select(summary => generation.GetInFlight(summary.Id) ?? summary)
|
||||
.Select(summary =>
|
||||
{
|
||||
var live = generation.GetInFlight(summary.Id) ?? summary;
|
||||
return simulation.Overlay(live);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
return Results.Ok(new WorldListDto
|
||||
@@ -47,7 +56,7 @@ public static class WorldEndpoints
|
||||
try
|
||||
{
|
||||
var summary = await generation.StartAsync(request, cancellationToken);
|
||||
return Results.Created($"/api/worlds/{summary.Id}", summary);
|
||||
return Results.Created($"/api/worlds/{summary.Id}", summary with { LastTickedAt = null });
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
@@ -71,14 +80,16 @@ public static class WorldEndpoints
|
||||
string id,
|
||||
WorldStore store,
|
||||
WorldGenerationService generation,
|
||||
WorldSimulationHost simulation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||
|
||||
if (generation.GetInFlight(id) is { } live) return Results.Ok(live);
|
||||
if (generation.GetInFlight(id) is { } live)
|
||||
return Results.Ok(simulation.Overlay(live));
|
||||
|
||||
var summary = await store.GetSummaryAsync(id, cancellationToken);
|
||||
return summary is null ? Results.NotFound() : Results.Ok(summary);
|
||||
return summary is null ? Results.NotFound() : Results.Ok(simulation.Overlay(summary));
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetMap(string id, WorldStore store, CancellationToken cancellationToken)
|
||||
@@ -101,9 +112,62 @@ public static class WorldEndpoints
|
||||
return Results.Stream(stream, "application/json");
|
||||
}
|
||||
|
||||
private static IResult DeleteWorld(string id, WorldStore store)
|
||||
private static async Task<IResult> UpdateClock(
|
||||
string id,
|
||||
UpdateClockRequest request,
|
||||
WorldStore store,
|
||||
WorldSimulationHost simulation,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!WorldStore.IsValidId(id)) return Results.NotFound();
|
||||
|
||||
if (request.TimeScale is { } scale && !GameTime.IsValidTimeScale(scale))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TheLivingWorld.Api.Simulation;
|
||||
using TheLivingWorld.Api.Storage;
|
||||
using TheLivingWorld.Core.Contracts;
|
||||
using TheLivingWorld.Core.Export;
|
||||
@@ -20,6 +21,7 @@ public sealed class WorldGenerationService(
|
||||
OsmWorldGenerator generator,
|
||||
WorldStore store,
|
||||
ChunkExporter exporter,
|
||||
WorldSimulationHost simulation,
|
||||
IOptions<WorldStorageOptions> storageOptions,
|
||||
IHostApplicationLifetime lifetime,
|
||||
ILogger<WorldGenerationService> logger) : IDisposable
|
||||
@@ -59,6 +61,7 @@ public sealed class WorldGenerationService(
|
||||
Status = WorldStatus.Pending,
|
||||
Stage = "Queued",
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Clock = WorldSimulation.DefaultClock(),
|
||||
};
|
||||
|
||||
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -146,9 +149,12 @@ public sealed class WorldGenerationService(
|
||||
Water = stats.Water,
|
||||
Vertices = stats.Vertices,
|
||||
},
|
||||
Clock = summary.Clock ?? WorldSimulation.DefaultClock(),
|
||||
LastTickedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
|
||||
await store.SaveSummaryAsync(ready, CancellationToken.None).ConfigureAwait(false);
|
||||
simulation.Attach(ready);
|
||||
Publish(ready);
|
||||
_inFlight.TryRemove(summary.Id, out _);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.IO.Compression;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using TheLivingWorld.Api.Endpoints;
|
||||
using TheLivingWorld.Api.Generation;
|
||||
using TheLivingWorld.Api.Simulation;
|
||||
using TheLivingWorld.Api.Storage;
|
||||
using TheLivingWorld.Core.Contracts;
|
||||
using TheLivingWorld.Core.Export;
|
||||
@@ -35,6 +36,8 @@ builder.Services.PostConfigure<OsmOptions>(options =>
|
||||
|
||||
builder.Services.AddSingleton<WorldStore>();
|
||||
builder.Services.AddSingleton<ChunkExporter>();
|
||||
builder.Services.AddSingleton<WorldSimulationHost>();
|
||||
builder.Services.AddHostedService(provider => provider.GetRequiredService<WorldSimulationHost>());
|
||||
builder.Services.AddSingleton<WorldGenerationService>();
|
||||
|
||||
// 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 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>
|
||||
|
||||
@@ -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>
|
||||
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>
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { CreateWorldRequest, MapChunk, WorldList, WorldMap, WorldSummary } from './types';
|
||||
import type {
|
||||
CreateWorldRequest,
|
||||
MapChunk,
|
||||
UpdateClockRequest,
|
||||
WorldClock,
|
||||
WorldList,
|
||||
WorldMap,
|
||||
WorldSummary,
|
||||
} from './types';
|
||||
|
||||
const BASE = '/api/worlds';
|
||||
|
||||
@@ -47,6 +55,13 @@ export const api = {
|
||||
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
|
||||
request<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> => {
|
||||
const response = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) throw new Error(await describeFailure(response));
|
||||
|
||||
@@ -21,6 +21,19 @@ export interface WorldSummary {
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
stats?: WorldStats;
|
||||
clock?: WorldClock;
|
||||
}
|
||||
|
||||
/** In-world calendar. gameTime is a naive local datetime string (no Z). */
|
||||
export interface WorldClock {
|
||||
gameTime: string;
|
||||
timeScale: number;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateClockRequest {
|
||||
paused?: boolean;
|
||||
timeScale?: number;
|
||||
}
|
||||
|
||||
/** Response body for GET /api/worlds. */
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import './styles.css';
|
||||
import { api, waitForWorld } from './api/client';
|
||||
import type { WorldSummary } from './api/types';
|
||||
import type { WorldClock, WorldSummary } from './api/types';
|
||||
import { MapView, type MapStatus } from './map/mapView';
|
||||
import { THEMES, type ThemeName } from './map/theme';
|
||||
import { formatCoordinates, parseCoordinates } from './ui/coordinates';
|
||||
import { formatGameTime, formatGameTimeRaw, interpolateGameTime } from './ui/gameTime';
|
||||
|
||||
const LAST_WORLD_KEY = 'the-living-world:last-world';
|
||||
const THEME_KEY = 'the-living-world:theme';
|
||||
const MENU_POLL_MS = 2000;
|
||||
const GAME_POLL_MS = 1000;
|
||||
const CLOCK_PAINT_MS = 250;
|
||||
|
||||
const elements = {
|
||||
menu: required<HTMLDivElement>('menu'),
|
||||
@@ -31,6 +35,10 @@ const elements = {
|
||||
back: required<HTMLButtonElement>('back-button'),
|
||||
menuThemeToggle: required<HTMLButtonElement>('menu-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();
|
||||
@@ -41,6 +49,18 @@ let mapReady = false;
|
||||
let generating = false;
|
||||
let continueWorldId: string | null = null;
|
||||
|
||||
/** Latest list from the API, used to refresh clock labels between polls. */
|
||||
let listedWorlds: WorldSummary[] = [];
|
||||
let menuSnapshotAt = 0;
|
||||
let menuPollTimer: number | null = null;
|
||||
let menuPaintTimer: number | null = null;
|
||||
|
||||
let gameClock: WorldClock | null = null;
|
||||
let gameSnapshotAt = 0;
|
||||
let gamePollTimer: number | null = null;
|
||||
let gamePaintTimer: number | null = null;
|
||||
let clockUpdating = false;
|
||||
|
||||
function required<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) throw new Error(`Missing element #${id}`);
|
||||
@@ -55,11 +75,14 @@ function setStatus(message: string, tone: 'info' | 'error' | 'busy' = 'info'): v
|
||||
function showMenu(): void {
|
||||
elements.menu.hidden = false;
|
||||
elements.game.hidden = true;
|
||||
stopGameClockLoop();
|
||||
startMenuClockLoop();
|
||||
}
|
||||
|
||||
function showGame(): void {
|
||||
elements.menu.hidden = true;
|
||||
elements.game.hidden = false;
|
||||
stopMenuClockLoop();
|
||||
}
|
||||
|
||||
function renderHud(status: MapStatus): void {
|
||||
@@ -106,11 +129,26 @@ function sortWorlds(worlds: WorldSummary[]): WorldSummary[] {
|
||||
});
|
||||
}
|
||||
|
||||
function displayClock(clock: WorldClock | undefined, snapshotAt: number): string | null {
|
||||
if (!clock) return null;
|
||||
const date = interpolateGameTime(clock, performance.now() - snapshotAt);
|
||||
return date ? formatGameTime(date) : formatGameTimeRaw(clock.gameTime);
|
||||
}
|
||||
|
||||
function updateContinue(worlds: WorldSummary[]): void {
|
||||
const last = worlds.find((world) => world.id === lastWorldId() && world.status === 'ready');
|
||||
continueWorldId = last?.id ?? null;
|
||||
elements.continue.hidden = last === undefined;
|
||||
elements.continueName.textContent = last?.name ?? '';
|
||||
|
||||
if (!last) {
|
||||
elements.continueName.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const clockLabel = displayClock(last.clock, menuSnapshotAt);
|
||||
elements.continueName.textContent = clockLabel
|
||||
? `${last.name} · ${clockLabel}`
|
||||
: last.name;
|
||||
}
|
||||
|
||||
async function refreshWorldList(): Promise<WorldSummary[]> {
|
||||
@@ -119,6 +157,9 @@ async function refreshWorldList(): Promise<WorldSummary[]> {
|
||||
worldCount = list.worlds.length;
|
||||
elements.slotCount.textContent = `${worldCount} / ${maxConcurrentWorlds}`;
|
||||
|
||||
listedWorlds = list.worlds;
|
||||
menuSnapshotAt = performance.now();
|
||||
|
||||
const worlds = sortWorlds(list.worlds);
|
||||
elements.worldsEmpty.hidden = worlds.length > 0;
|
||||
elements.worldList.replaceChildren(...worlds.map(renderWorldItem));
|
||||
@@ -127,6 +168,43 @@ async function refreshWorldList(): Promise<WorldSummary[]> {
|
||||
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 {
|
||||
if (world.status === 'pending' || world.status === 'generating') return world.stage ?? 'Generating';
|
||||
if (world.status === 'failed') return 'Failed';
|
||||
@@ -138,6 +216,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
||||
const item = document.createElement('li');
|
||||
item.className = 'world';
|
||||
item.dataset.status = world.status;
|
||||
item.dataset.id = world.id;
|
||||
if (world.id === lastWorldId() && world.status === 'ready') {
|
||||
item.dataset.last = 'true';
|
||||
}
|
||||
@@ -168,7 +247,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
||||
|
||||
const detail = document.createElement('span');
|
||||
detail.className = 'world__detail';
|
||||
detail.textContent = describeWorld(world);
|
||||
detail.textContent = describeWorld(world, menuSnapshotAt);
|
||||
|
||||
open.append(nameRow, detail);
|
||||
open.addEventListener('click', () => {
|
||||
@@ -189,14 +268,97 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
|
||||
return item;
|
||||
}
|
||||
|
||||
function describeWorld(world: WorldSummary): string {
|
||||
function describeWorld(world: WorldSummary, snapshotAt = menuSnapshotAt): string {
|
||||
if (world.status === 'failed') return world.error ?? 'Generation failed';
|
||||
if (world.status !== 'ready') return formatCoordinates(world.latitude, world.longitude, 4);
|
||||
|
||||
const size = `${(world.sizeMeters / 1000).toFixed(0)} km`;
|
||||
if (!world.stats) return size;
|
||||
const clockLabel = displayClock(world.clock, snapshotAt);
|
||||
const stats = world.stats
|
||||
? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`
|
||||
: null;
|
||||
|
||||
return `${size} · ${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`;
|
||||
if (clockLabel && stats) return `${clockLabel} · ${size} · ${stats}`;
|
||||
if (clockLabel) return `${clockLabel} · ${size}`;
|
||||
if (stats) return `${size} · ${stats}`;
|
||||
return size;
|
||||
}
|
||||
|
||||
function applyClockToControls(clock: WorldClock): void {
|
||||
gameClock = clock;
|
||||
gameSnapshotAt = performance.now();
|
||||
elements.simControls.hidden = false;
|
||||
paintGameClock();
|
||||
|
||||
elements.playPause.textContent = clock.paused ? '▶' : '⏸';
|
||||
elements.playPause.title = clock.paused ? 'Play' : 'Pause';
|
||||
elements.playPause.setAttribute('aria-label', clock.paused ? 'Play' : 'Pause');
|
||||
|
||||
for (const button of elements.speedButtons) {
|
||||
const scale = Number(button.dataset.scale);
|
||||
button.setAttribute('aria-pressed', scale === clock.timeScale ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function paintGameClock(): void {
|
||||
if (!gameClock) {
|
||||
elements.gameClock.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const date = interpolateGameTime(gameClock, performance.now() - gameSnapshotAt);
|
||||
elements.gameClock.textContent = date
|
||||
? formatGameTime(date)
|
||||
: formatGameTimeRaw(gameClock.gameTime);
|
||||
}
|
||||
|
||||
function startGameClockLoop(worldId: string): void {
|
||||
stopGameClockLoop();
|
||||
|
||||
gamePollTimer = window.setInterval(() => {
|
||||
void pollGameClock(worldId);
|
||||
}, GAME_POLL_MS);
|
||||
gamePaintTimer = window.setInterval(paintGameClock, CLOCK_PAINT_MS);
|
||||
}
|
||||
|
||||
function stopGameClockLoop(): void {
|
||||
if (gamePollTimer !== null) {
|
||||
window.clearInterval(gamePollTimer);
|
||||
gamePollTimer = null;
|
||||
}
|
||||
if (gamePaintTimer !== null) {
|
||||
window.clearInterval(gamePaintTimer);
|
||||
gamePaintTimer = null;
|
||||
}
|
||||
gameClock = null;
|
||||
elements.simControls.hidden = true;
|
||||
elements.gameClock.textContent = '';
|
||||
}
|
||||
|
||||
async function pollGameClock(worldId: string): Promise<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> {
|
||||
@@ -214,7 +376,7 @@ async function openWorld(id: string): Promise<void> {
|
||||
// leaves the canvas stuck as a thin strip.
|
||||
showGame();
|
||||
await ensureMap();
|
||||
const map = await api.getMap(id);
|
||||
const [map, summary] = await Promise.all([api.getMap(id), api.getWorld(id)]);
|
||||
|
||||
activeWorldId = id;
|
||||
localStorage.setItem(LAST_WORLD_KEY, id);
|
||||
@@ -223,6 +385,10 @@ async function openWorld(id: string): Promise<void> {
|
||||
elements.worldTitle.textContent = map.name;
|
||||
elements.hud.textContent = '';
|
||||
setStatus('');
|
||||
|
||||
if (summary.clock) applyClockToControls(summary.clock);
|
||||
startGameClockLoop(id);
|
||||
|
||||
await refreshWorldList();
|
||||
} catch (error) {
|
||||
showMenu();
|
||||
@@ -232,6 +398,7 @@ async function openWorld(id: string): Promise<void> {
|
||||
|
||||
async function returnToMenu(): Promise<void> {
|
||||
activeWorldId = null;
|
||||
stopGameClockLoop();
|
||||
if (mapReady) view.clear();
|
||||
elements.hud.textContent = '';
|
||||
elements.worldTitle.textContent = '';
|
||||
@@ -252,6 +419,7 @@ async function deleteWorld(world: WorldSummary): Promise<void> {
|
||||
|
||||
if (activeWorldId === world.id) {
|
||||
activeWorldId = null;
|
||||
stopGameClockLoop();
|
||||
localStorage.removeItem(LAST_WORLD_KEY);
|
||||
if (mapReady) view.clear();
|
||||
elements.hud.textContent = '';
|
||||
@@ -376,6 +544,17 @@ async function start(): Promise<void> {
|
||||
});
|
||||
elements.menuThemeToggle.addEventListener('click', toggleTheme);
|
||||
elements.gameThemeToggle.addEventListener('click', toggleTheme);
|
||||
elements.playPause.addEventListener('click', () => {
|
||||
if (!gameClock) return;
|
||||
void patchClock({ paused: !gameClock.paused });
|
||||
});
|
||||
for (const button of elements.speedButtons) {
|
||||
button.addEventListener('click', () => {
|
||||
const scale = Number(button.dataset.scale);
|
||||
if (!Number.isFinite(scale) || scale === gameClock?.timeScale) return;
|
||||
void patchClock({ timeScale: scale });
|
||||
});
|
||||
}
|
||||
|
||||
applyTheme(readStoredTheme());
|
||||
showMenu();
|
||||
|
||||
@@ -143,6 +143,62 @@ body {
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.sim-controls {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px 4px 10px;
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.sim-controls__clock {
|
||||
min-width: 11.5rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sim-controls__speeds {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.speed-button {
|
||||
min-width: 32px;
|
||||
height: 26px;
|
||||
padding: 0 6px;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.speed-button:hover {
|
||||
color: var(--text);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.speed-button[aria-pressed='true'] {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.speed-button[aria-pressed='true']:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
flex: none;
|
||||
width: 30px;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user