Refactor world simulation and API to enhance world state management; introduce StoredWorldDto for internal bookkeeping, improve synchronization between simulation and API data, and implement new simulation options for idle and catch-up behavior. Update documentation to reflect changes in data structures and API endpoints.

This commit is contained in:
Leonid Pershin
2026-08-16 23:28:29 +03:00
parent 2a8b7b49b3
commit cb5117edba
23 changed files with 639 additions and 121 deletions
+3
View File
@@ -74,6 +74,9 @@ day, cloud, fog and lying snow and drops rain or snow through it.
New ECS components must be added to the probe entity in `SimulationComponents` — Arch assigns component type
ids on first use without a lock, and two threads racing there hand out the same id.
`StoredWorldDto` is what `state.json` holds; `WorldSummaryDto` is what clients get. Cross only via
`ToSummary()` — the wire type deliberately has no field for the simulation's bookkeeping.
Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
## Working conventions
+16
View File
@@ -113,6 +113,11 @@ them without reworking the data model.
| `DELETE /api/worlds/{id}` | Remove a world and its chunks |
| `GET /api/climates` | The climate catalogue for the create form, with the latitude band each preset is the default for |
`state.json` and the API do not share a type. `StoredWorldDto` holds what the simulation needs to resume — the
last tick stamp and the drifting pressure systems — and `WorldSummaryDto` holds what clients see. The only way
from one to the other is `ToSummary()`, so a new endpoint cannot publish the internals by forgetting to strip
them; the wire type has no field that could carry them.
Generation takes tens of seconds — mostly waiting on Overpass — so `POST` returns straight away and the client
polls for status. Only one generation runs at a time, to stay a good citizen on the shared Overpass mirrors.
The number of worlds that may exist at once is capped by `WorldStorage:MaxConcurrentWorlds` (today that means
@@ -136,6 +141,13 @@ The drifting systems are persisted in `state.json` so a restart resumes the sky
than a game day away and the model rolls a fresh sky for the season instead — stepping days of drift in one
jump is not a simulation, it is a teleport.
Time does not run without limit while nobody is here. A world banks at most `Simulation:MaxCatchUpGameHours`
of in-world time per step, so a host that was down for a week wakes its worlds a day older rather than years.
Worlds nobody is looking at also tick lazily, on `Simulation:IdleTickSeconds` instead of every pass. That
costs no accuracy — a step is driven by the wall time since *that* world last ticked, so one long step and
fifty short ones land on the same game time — and reading a world brings it current before answering, which
keeps the work proportional to how much anyone is actually watching.
Snow is the one part of the weather with memory. Everything else is a function of the current instant, but
you cannot tell how deep the snow lies without knowing what the sky did for the last few days, so it is
integrated as the world ticks and stored alongside the pressure systems. A world created in a Siberian
@@ -210,6 +222,10 @@ usual dissolve instead of a special case. The page chrome follows via a `data-th
- `WorldStorage:RootDirectory` — where generated worlds go (default `data/worlds`)
- `WorldStorage:MaxConcurrentWorlds` — how many worlds may exist at once (default `8`)
- `Simulation:MaxCatchUpGameHours` — in-world time a world may bank per step, so downtime does not cost years
(default `24`; `0` removes the limit)
- `Simulation:IdleAfterSeconds` — how long after the last request a world stops counting as watched (default `20`)
- `Simulation:IdleTickSeconds` — tick spacing for unwatched worlds (default `5`)
- `Osm:Endpoints` — Overpass mirrors, tried in order
- `Osm:CacheDirectory` — raw Overpass responses (default `data/osm-cache`)
- `Osm:QueryTimeoutSeconds` / `Osm:RequestTimeoutSeconds` — server-side and client-side budgets
@@ -101,6 +101,10 @@ public static class WorldEndpoints
{
if (!WorldStore.IsValidId(id)) return Results.NotFound();
// Asking for one world means somebody has it open, which keeps it ticking at full rate. The listing
// endpoint deliberately does not do this: it asks for every world every couple of seconds.
simulation.Touch(id);
if (generation.GetInFlight(id) is { } live)
return Results.Ok(simulation.Overlay(live));
@@ -136,6 +140,8 @@ public static class WorldEndpoints
{
if (!WorldStore.IsValidId(id)) return Results.NotFound();
simulation.Touch(id);
// A world that exists but is not running has no weather to report - the field only means anything
// while pressure systems are actually drifting.
if (simulation.TryGetWeatherField(id) is { } field) return Results.Ok(field);
@@ -36,9 +36,9 @@ public sealed class WorldGenerationService(
/// <summary>Serialises capacity checks against concurrent create requests.</summary>
private readonly SemaphoreSlim _capacityGate = new(1, 1);
private readonly ConcurrentDictionary<string, WorldSummaryDto> _inFlight = new();
private readonly ConcurrentDictionary<string, StoredWorldDto> _inFlight = new();
public async Task<WorldSummaryDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
public async Task<StoredWorldDto> StartAsync(CreateWorldRequest request, CancellationToken cancellationToken)
{
var origin = new GeoPoint(request.Latitude, request.Longitude);
if (!origin.IsValid)
@@ -66,7 +66,7 @@ public sealed class WorldGenerationService(
: request.Name.Trim();
var id = CreateId(name);
var summary = new WorldSummaryDto
var summary = new StoredWorldDto
{
Id = id,
Name = name,
@@ -106,11 +106,11 @@ public sealed class WorldGenerationService(
}
/// <summary>Returns the live status of a generation still in progress, if there is one.</summary>
public WorldSummaryDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
public StoredWorldDto? GetInFlight(string id) => _inFlight.GetValueOrDefault(id);
public int MaxConcurrentWorlds => storageOptions.Value.MaxConcurrentWorlds;
private async Task RunAsync(WorldSummaryDto summary, bool forceRefresh)
private async Task RunAsync(StoredWorldDto summary, bool forceRefresh)
{
// Generation should stop when the host does, not drag shutdown out for minutes.
var cancellationToken = lifetime.ApplicationStopping;
@@ -202,7 +202,7 @@ public sealed class WorldGenerationService(
}
}
private void Publish(WorldSummaryDto summary) => _inFlight[summary.Id] = summary;
private void Publish(StoredWorldDto summary) => _inFlight[summary.Id] = summary;
private static WorldDto ToDto(GameWorld world, IReadOnlyList<ExportedChunk> chunks)
{
+3
View File
@@ -19,6 +19,9 @@ builder.Services.ConfigureHttpJsonOptions(options => MapJson.Apply(options.Seria
builder.Services.Configure<WorldStorageOptions>(
builder.Configuration.GetSection(WorldStorageOptions.SectionName));
builder.Services.Configure<SimulationOptions>(
builder.Configuration.GetSection(SimulationOptions.SectionName));
// Relative paths in configuration are meant to sit next to the app, not next to whatever the working
// directory happens to be when it is launched.
builder.Services.PostConfigure<WorldStorageOptions>(options =>
@@ -0,0 +1,33 @@
namespace TheLivingWorld.Api.Simulation;
/// <summary>Knobs for the live simulation. Bound from the <c>Simulation</c> configuration section.</summary>
public sealed class SimulationOptions
{
public const string SectionName = "Simulation";
/// <summary>
/// The most in-world time a single step may bank. This is what a world does with the hours nobody was
/// watching: come back after a week and the town has moved on by this much, not by two and a half years.
/// Zero means no limit.
/// </summary>
public double MaxCatchUpGameHours { get; set; } = 24;
/// <summary>
/// How long after the last request for a world it stops being watched. The menu listing does not count;
/// only opening a world's own endpoints does.
/// </summary>
public double IdleAfterSeconds { get; set; } = 20;
/// <summary>
/// Tick spacing for worlds nobody is watching. Accuracy is unaffected: a step is driven by the wall time
/// since that world last ticked, so fifty small steps and one large one land on the same game time, and
/// reading a world brings it current before answering. This only decides who does the work and when.
/// </summary>
public double IdleTickSeconds { get; set; } = 5;
public TimeSpan MaxCatchUp => TimeSpan.FromHours(Math.Max(MaxCatchUpGameHours, 0));
public TimeSpan IdleAfter => TimeSpan.FromSeconds(Math.Max(IdleAfterSeconds, 0));
public TimeSpan IdleTickInterval => TimeSpan.FromSeconds(Math.Max(IdleTickSeconds, 0));
}
@@ -12,17 +12,20 @@ namespace TheLivingWorld.Api.Simulation;
public sealed class WorldSimulation : IDisposable
{
/// <summary>
/// A gap longer than this is reseeded rather than stepped. The pressure systems that were drifting when
/// the host went down are long gone by then, and replaying days of them would cost more than it is worth.
/// A step longer than this stops being a simulation of the weather and becomes a teleport: the pressure
/// systems would cross the map and be recycled several times over inside one jump. Deliberately shorter
/// than the clock's own cap, because the two are limited for different reasons.
/// </summary>
private static readonly TimeSpan MaxWeatherCatchUp = TimeSpan.FromHours(24);
private static readonly TimeSpan MaxWeatherStep = TimeSpan.FromHours(6);
private readonly object _gate = new();
private readonly World _ecs;
private readonly Entity _clockEntity;
private readonly ClimatePreset _climate;
private readonly double _latitude;
private readonly TimeSpan _maxCatchUp;
private DateTimeOffset _lastTickedAt;
private DateTimeOffset _lastViewedAt;
private bool _dirty;
private bool _disposed;
@@ -32,6 +35,7 @@ public sealed class WorldSimulation : IDisposable
Entity clockEntity,
ClimatePreset climate,
double latitude,
TimeSpan maxCatchUp,
DateTimeOffset lastTickedAt)
{
WorldId = worldId;
@@ -39,7 +43,10 @@ public sealed class WorldSimulation : IDisposable
_clockEntity = clockEntity;
_climate = climate;
_latitude = latitude;
_maxCatchUp = maxCatchUp;
_lastTickedAt = lastTickedAt;
// A world is watched the moment it attaches; nobody has had a chance to ask for it yet.
_lastViewedAt = DateTimeOffset.UtcNow;
}
public string WorldId { get; }
@@ -58,11 +65,16 @@ public sealed class WorldSimulation : IDisposable
/// 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)
public static WorldSimulation Create(
StoredWorldDto summary,
bool catchUp = true,
SimulationOptions? options = null)
{
ArgumentNullException.ThrowIfNull(summary);
SimulationComponents.EnsureRegistered();
var settings = options ?? new SimulationOptions();
var clock = summary.Clock ?? DefaultClock();
var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale;
var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified);
@@ -74,7 +86,8 @@ public sealed class WorldSimulation : IDisposable
RestoreWeather(ecs, summary, climate, gameTime);
var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
var simulation = new WorldSimulation(summary.Id, ecs, entity, climate, summary.Latitude, lastTickedAt);
var simulation = new WorldSimulation(
summary.Id, ecs, entity, climate, summary.Latitude, settings.MaxCatchUp, lastTickedAt);
if (catchUp && !clock.Paused)
{
@@ -92,7 +105,7 @@ public sealed class WorldSimulation : IDisposable
private static void RestoreWeather(
World ecs,
WorldSummaryDto summary,
StoredWorldDto summary,
ClimatePreset climate,
DateTime gameTime)
{
@@ -186,15 +199,20 @@ public sealed class WorldSimulation : IDisposable
{
// Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
// allocate for nothing.
var before = _ecs.Get<GameClock>(_clockEntity).Ticks;
ClockSystem.Execute(_ecs, realElapsed);
var elapsedGameTicks = _ecs.Get<GameClock>(_clockEntity).Ticks - before;
var before = _ecs.Get<GameClock>(_clockEntity);
// The hours nobody was here for are capped rather than replayed. A normal tick is a tenth of a
// second and never comes near the limit; this only bites after the host has been down.
var banked = GameTime.LimitCatchUp(realElapsed, before.TimeScale, _maxCatchUp);
ClockSystem.Execute(_ecs, banked);
var elapsedGameTicks = _ecs.Get<GameClock>(_clockEntity).Ticks - before.Ticks;
if (elapsedGameTicks <= 0) return false;
var elapsedGame = TimeSpan.FromTicks(elapsedGameTicks);
var gameTime = new DateTime(_ecs.Get<GameClock>(_clockEntity).Ticks, DateTimeKind.Unspecified);
if (elapsedGame > MaxWeatherCatchUp)
if (elapsedGame > MaxWeatherStep)
{
// One giant step is not a simulation: the systems that were drifting would have blown through
// and been replaced many times over. Roll a fresh sky for the season we landed in instead.
@@ -238,6 +256,23 @@ public sealed class WorldSimulation : IDisposable
}
}
/// <summary>
/// Records that somebody asked for this world. Any player counts - worlds are shared, so one viewer is
/// enough to keep it running at full rate for everyone.
/// </summary>
public void Touch()
{
lock (_gate) _lastViewedAt = DateTimeOffset.UtcNow;
}
/// <summary>True when nobody has asked for this world recently, so it can afford to tick lazily.</summary>
public bool IsIdle(DateTimeOffset now, TimeSpan idleAfter)
{
if (idleAfter <= TimeSpan.Zero) return false;
lock (_gate) return now - _lastViewedAt > idleAfter;
}
/// <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.
@@ -330,7 +365,8 @@ public sealed class WorldSimulation : IDisposable
return new WeatherDto
{
SnowDepthMm = Math.Round(WeatherSystem.SnowDepthMm(_ecs), 1),
SnowDepthMm = Math.Round(
WeatherModel.LocalSnowDepth(WeatherSystem.SnowDepthMm(_ecs), sample.TemperatureC), 1),
Condition = sample.Condition,
TemperatureC = Math.Round(sample.TemperatureC, 1),
FeelsLikeC = Math.Round(sample.FeelsLikeC, 1),
@@ -343,7 +379,7 @@ public sealed class WorldSimulation : IDisposable
};
}
public WorldSummaryDto ApplyTo(WorldSummaryDto summary)
public StoredWorldDto ApplyTo(StoredWorldDto summary)
{
lock (_gate)
{
@@ -358,8 +394,11 @@ public sealed class WorldSimulation : IDisposable
}
}
/// <summary>Wire-facing snapshot: live clock and weather, none of the storage-only bookkeeping.</summary>
public WorldSummaryDto OverlayForApi(WorldSummaryDto summary)
/// <summary>
/// Wire-facing snapshot: the stored facts projected to the client shape, with the live clock and weather
/// laid over them. The storage bookkeeping cannot come along - the type it would go in has no room for it.
/// </summary>
public WorldSummaryDto OverlayForApi(StoredWorldDto summary)
{
lock (_gate)
{
@@ -368,13 +407,11 @@ public sealed class WorldSimulation : IDisposable
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
return summary with
return summary.ToSummary() with
{
Clock = SnapshotClockUnlocked(),
Climate = _climate.Kind,
Weather = SampleUnlocked(systems[..count], 0.5f, 0.5f),
LastTickedAt = null,
WeatherState = null,
};
}
}
@@ -1,15 +1,17 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
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.
/// Hosts live clock simulations for every Ready world. Watched worlds tick ~10 Hz and worlds nobody has open
/// tick lazily; state is persisted every few seconds, and wall-clock gaps are caught up after restart.
/// </summary>
public sealed class WorldSimulationHost(
WorldStore store,
IOptions<SimulationOptions> options,
ILogger<WorldSimulationHost> logger) : BackgroundService
{
private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100);
@@ -18,8 +20,10 @@ public sealed class WorldSimulationHost(
private readonly ConcurrentDictionary<string, WorldSimulation> _simulations = new();
private readonly SemaphoreSlim _persistGate = new(1, 1);
private SimulationOptions Settings => options.Value;
/// <summary>Attaches a Ready world if it is not already running. Idempotent.</summary>
public void Attach(WorldSummaryDto summary)
public void Attach(StoredWorldDto summary)
{
ArgumentNullException.ThrowIfNull(summary);
if (summary.Status != WorldStatus.Ready) return;
@@ -41,21 +45,42 @@ public sealed class WorldSimulationHost(
}
public WorldClockDto? TryGetClock(string id) =>
_simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotClock() : null;
Current(id)?.SnapshotClock();
/// <summary>
/// The one projection from stored/in-flight state to what clients see: overlays the live clock and
/// weather when the world is running, and always drops the storage-only fields
/// (<see cref="WorldSummaryDto.LastTickedAt"/>, <see cref="WorldSummaryDto.WeatherState"/>).
/// Every endpoint that returns a summary must go through here.
/// Fetches a simulation and brings it up to the current instant first. A world nobody is watching ticks
/// lazily, so its clock can be a few seconds behind - which is free until somebody reads it, and wrong
/// the moment they do. Reading does the skipped work rather than reporting a stale answer, which keeps
/// the cost proportional to how much anyone is actually looking.
/// </summary>
public WorldSummaryDto Overlay(WorldSummaryDto summary) =>
_simulations.TryGetValue(summary.Id, out var simulation)
? simulation.OverlayForApi(summary)
: summary with { LastTickedAt = null, WeatherState = null };
private WorldSimulation? Current(string id)
{
if (!_simulations.TryGetValue(id, out var simulation)) return null;
public WeatherFieldDto? TryGetWeatherField(string id) =>
_simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotWeatherField() : null;
try
{
var elapsed = DateTimeOffset.UtcNow - simulation.LastTickedAt;
if (elapsed > TimeSpan.Zero) simulation.Tick(elapsed);
}
catch (Exception ex)
{
logger.LogError(ex, "Could not bring world {Id} up to date", id);
}
return simulation;
}
/// <summary>
/// The projection from stored state to what clients see: the live clock and weather when the world is
/// running, and the plain stored facts when it is not. Storage bookkeeping never comes along, because
/// <see cref="WorldSummaryDto"/> has nowhere to put it.
/// </summary>
public WorldSummaryDto Overlay(StoredWorldDto summary) =>
Current(summary.Id) is { } simulation
? simulation.OverlayForApi(summary)
: summary.ToSummary();
public WeatherFieldDto? TryGetWeatherField(string id) => Current(id)?.SnapshotWeatherField();
public WorldClockDto UpdateClock(string id, UpdateClockRequest request)
{
@@ -67,33 +92,23 @@ public sealed class WorldSimulationHost(
public bool IsAttached(string id) => _simulations.ContainsKey(id);
/// <summary>
/// Marks a world as being watched, which keeps it ticking at full rate. Call this from endpoints that
/// serve one world to somebody looking at it - never from the menu listing, which asks for every world
/// every couple of seconds and would keep the whole server awake.
/// </summary>
public void Touch(string id) => Current(id)?.Touch();
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await LoadReadyWorldsAsync(stoppingToken).ConfigureAwait(false);
var lastTick = TimeProvider.System.GetUtcNow();
var lastPersist = lastTick;
var lastPersist = TimeProvider.System.GetUtcNow();
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);
}
}
}
TickAll(now);
if (now - lastPersist >= PersistInterval)
{
@@ -112,6 +127,35 @@ public sealed class WorldSimulationHost(
}
}
/// <summary>
/// Steps every world by the wall time since <em>that world</em> last ticked, rather than by the loop's
/// own interval. That makes the tick rate a pure cost decision: a world stepped once every five seconds
/// with a five-second slice lands on exactly the same game time as one stepped fifty times.
/// </summary>
private void TickAll(DateTimeOffset now)
{
var idleAfter = Settings.IdleAfter;
var idleInterval = Settings.IdleTickInterval;
foreach (var simulation in _simulations.Values)
{
try
{
var elapsed = now - simulation.LastTickedAt;
if (elapsed <= TimeSpan.Zero) continue;
// Watched worlds tick every pass; the rest wait their turn.
if (simulation.IsIdle(now, idleAfter) && elapsed < idleInterval) continue;
simulation.Tick(elapsed);
}
catch (Exception ex)
{
logger.LogError(ex, "Clock tick failed for world {Id}", simulation.WorldId);
}
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
// Let the tick loop finish first so the final write captures the very last game time, then persist
@@ -134,7 +178,7 @@ public sealed class WorldSimulationHost(
private async Task LoadReadyWorldsAsync(CancellationToken cancellationToken)
{
IReadOnlyList<WorldSummaryDto> listed;
IReadOnlyList<StoredWorldDto> listed;
try
{
listed = await store.ListAsync(cancellationToken).ConfigureAwait(false);
@@ -172,7 +216,7 @@ public sealed class WorldSimulationHost(
}
}
private void AttachSeeded(WorldSummaryDto summary)
private void AttachSeeded(StoredWorldDto summary)
{
if (AttachCore(summary, catchUp: false))
logger.LogInformation("Attached simulation for world {Id} (hydrated, no catch-up)", summary.Id);
@@ -183,18 +227,18 @@ public sealed class WorldSimulationHost(
/// be disposed explicitly: an Arch <c>World</c> lives in a static registry and is never reclaimed by the
/// GC, so dropping the instance would leak it for the lifetime of the process.
/// </summary>
private bool AttachCore(WorldSummaryDto summary, bool catchUp)
private bool AttachCore(StoredWorldDto summary, bool catchUp)
{
if (_simulations.ContainsKey(summary.Id)) return false;
var created = WorldSimulation.Create(summary, catchUp);
var created = WorldSimulation.Create(summary, catchUp, Settings);
if (ReferenceEquals(_simulations.GetOrAdd(summary.Id, created), created)) return true;
created.Dispose();
return false;
}
private static WorldSummaryDto EnsureClock(WorldSummaryDto summary) =>
private static StoredWorldDto EnsureClock(StoredWorldDto summary) =>
summary.Clock is not null
? summary
: summary with
+6 -6
View File
@@ -26,11 +26,11 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
id.Length <= 64 &&
id.All(static c => char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '-');
public async Task<IReadOnlyList<WorldSummaryDto>> ListAsync(CancellationToken cancellationToken = default)
public async Task<IReadOnlyList<StoredWorldDto>> ListAsync(CancellationToken cancellationToken = default)
{
if (!Directory.Exists(_root)) return [];
var summaries = new List<WorldSummaryDto>();
var summaries = new List<StoredWorldDto>();
foreach (var directory in Directory.EnumerateDirectories(_root))
{
var id = Path.GetFileName(directory);
@@ -63,7 +63,7 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
return count;
}
public async Task<WorldSummaryDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
public async Task<StoredWorldDto?> GetSummaryAsync(string id, CancellationToken cancellationToken = default)
{
var path = Path.Combine(WorldDirectory(id), StateFileName);
if (!File.Exists(path)) return null;
@@ -72,7 +72,7 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
{
await using var stream = File.OpenRead(path);
return await JsonSerializer
.DeserializeAsync<WorldSummaryDto>(stream, MapJson.Options, cancellationToken)
.DeserializeAsync<StoredWorldDto>(stream, MapJson.Options, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex) when (ex is JsonException or IOException)
@@ -82,7 +82,7 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
}
}
public async Task SaveSummaryAsync(WorldSummaryDto summary, CancellationToken cancellationToken = default)
public async Task SaveSummaryAsync(StoredWorldDto summary, CancellationToken cancellationToken = default)
{
var directory = WorldDirectory(summary.Id);
Directory.CreateDirectory(directory);
@@ -95,7 +95,7 @@ public sealed class WorldStore(IOptions<WorldStorageOptions> options, ILogger<Wo
/// no map behind it.
/// </summary>
public async Task<bool> TryUpdateSummaryAsync(
WorldSummaryDto summary,
StoredWorldDto summary,
CancellationToken cancellationToken = default)
{
var path = Path.Combine(WorldDirectory(summary.Id), StateFileName);
+5
View File
@@ -10,6 +10,11 @@
"RootDirectory": "data/worlds",
"MaxConcurrentWorlds": 8
},
"Simulation": {
"MaxCatchUpGameHours": 24,
"IdleAfterSeconds": 20,
"IdleTickSeconds": 5
},
"Osm": {
"Endpoints": [
"https://overpass-api.de/api/interpreter",
@@ -46,7 +46,11 @@ public sealed record WorldListDto
public required int MaxConcurrentWorlds { get; init; }
}
/// <summary>A world as it appears in listings and while generation is still running.</summary>
/// <summary>
/// A world as clients see it, in listings and while generation is still running. This type has no storage
/// bookkeeping on it at all, which is what makes leaking any impossible rather than merely avoided - see
/// <see cref="StoredWorldDto"/> for the shape that goes on disk.
/// </summary>
public sealed record WorldSummaryDto
{
public required string Id { get; init; }
@@ -81,18 +85,67 @@ public sealed record WorldSummaryDto
/// <c>GET /api/worlds/{id}/weather</c>; only Ready worlds that are actually running carry this.
/// </summary>
public WeatherDto? Weather { 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>
/// <summary>
/// A world as <c>state.json</c> holds it: the same facts plus the bookkeeping the simulation needs to resume,
/// and none of the live values it can recompute. Never leaves the server - the only way out is
/// <see cref="ToSummary"/>, so a new endpoint cannot accidentally publish the internals.
/// </summary>
public sealed record StoredWorldDto
{
public required string Id { get; init; }
public required string Name { get; init; }
public required double Latitude { get; init; }
public required double Longitude { get; init; }
public required double SizeMeters { get; init; }
public required WorldStatus Status { get; init; }
public string? Stage { get; init; }
public string? Error { get; init; }
public required DateTimeOffset CreatedAt { get; init; }
public WorldStatsDto? Stats { get; init; }
public WorldClockDto? Clock { get; init; }
public ClimateKind? Climate { get; init; }
/// <summary>Wall-clock moment of the last simulation tick, for catch-up after a restart.</summary>
public DateTimeOffset? LastTickedAt { get; init; }
/// <summary>
/// The drifting pressure systems as they stood at the last persist, so a restart resumes the sky it had
/// instead of rolling a new one. Storage-only, stripped from API responses like <see cref="LastTickedAt"/>.
/// rather than rolling a new one.
/// </summary>
public WeatherStateDto? WeatherState { get; init; }
/// <summary>
/// Projects to the wire shape. Live values are overlaid afterwards by the simulation host; the storage
/// bookkeeping simply has nowhere to go, which is the point.
/// </summary>
public WorldSummaryDto ToSummary() => new()
{
Id = Id,
Name = Name,
Latitude = Latitude,
Longitude = Longitude,
SizeMeters = SizeMeters,
Status = Status,
Stage = Stage,
Error = Error,
CreatedAt = CreatedAt,
Stats = Stats,
Clock = Clock,
Climate = Climate,
};
}
/// <summary>Storage shape of a world's live weather. Never leaves the server.</summary>
@@ -167,8 +220,9 @@ public sealed record WeatherDto
public required double WindDirectionDeg { get; init; }
/// <summary>
/// Snow lying on the ground. World-wide rather than per point: over ten kilometres the cover really is
/// uniform, and it is the one weather value with memory, so it is integrated rather than sampled.
/// Snow lying on the ground at this point. The pack is integrated for the map as a whole - it is the one
/// weather value with memory - but what shows here is thinned by the local temperature, so cover goes
/// patchy over the warmer parts of the field instead of switching the whole map white at once.
/// </summary>
public required double SnowDepthMm { get; init; }
}
@@ -34,6 +34,22 @@ public static class GameTime
return current.AddTicks(gameTicks);
}
/// <summary>
/// Caps how much wall time one step may bank, so a world that was off for a week does not wake up years
/// older. Returns the real elapsed time that advances the calendar by at most
/// <paramref name="maxGameAdvance"/>; a non-positive cap means no limit.
/// </summary>
public static TimeSpan LimitCatchUp(TimeSpan realElapsed, int timeScale, TimeSpan maxGameAdvance)
{
if (realElapsed <= TimeSpan.Zero || maxGameAdvance <= TimeSpan.Zero) return realElapsed;
var scale = Math.Clamp(timeScale, MinTimeScale, MaxTimeScale);
var factor = (long)GameMinutesPerRealSecond * scale * 60;
var allowed = maxGameAdvance.Ticks / factor;
return realElapsed.Ticks <= allowed ? realElapsed : TimeSpan.FromTicks(allowed);
}
public static bool IsValidTimeScale(int timeScale) =>
timeScale is >= MinTimeScale and <= MaxTimeScale;
@@ -281,6 +281,19 @@ public static class WeatherModel
return Math.Clamp(current - melted, 0f, MaxSnowDepthMm);
}
/// <summary>
/// How much of the world's lying snow actually shows at a point this warm. The pack is integrated for
/// the map as a whole - over ten kilometres one snowfall really does cover all of it - but it goes patchy
/// where the air is warmer, so a map never flips from bare to white in a single step.
/// </summary>
public static float LocalSnowDepth(float depthMm, float temperatureC)
{
if (depthMm <= 0f) return 0f;
var thaw = Math.Clamp((temperatureC - FreezingC) / 8f, 0f, 1f);
return depthMm * (1f - (0.6f * thaw));
}
/// <summary>
/// A plausible depth of lying snow for a climate at this point in the year, without simulating the
/// winter that produced it. Used when a world is created and when one comes back from a long absence:
+28 -10
View File
@@ -12,7 +12,7 @@ import {
startGameTimeFromInput,
} from './ui/gameTime';
import { climateFromLatitude, describeClimate, findClimate } from './ui/climate';
import { describeWeather, formatWeather } from './ui/weather';
import { conditionIcon, describeWeather, formatTemperature, formatWeather } from './ui/weather';
const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme';
@@ -267,6 +267,7 @@ function renderWorldItem(world: WorldSummary): HTMLLIElement {
detail.textContent = describeWorld(world, menuSnapshotAt);
open.append(nameRow, detail);
open.title = worldTooltip(world);
open.addEventListener('click', () => {
void openWorld(world.id);
});
@@ -289,16 +290,33 @@ 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`;
const clockLabel = displayClock(world.clock, snapshotAt);
const stats = world.stats
? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`
: null;
// Enough to tell a town under snow from one in the tropics without opening either.
return [
displayClock(world.clock, snapshotAt),
world.weather
? `${conditionIcon(world.weather.condition)} ${formatTemperature(world.weather.temperatureC)}`
: null,
`${(world.sizeMeters / 1000).toFixed(0)} km`,
world.stats
? `${world.stats.buildings.toLocaleString()} buildings · ${world.stats.roads.toLocaleString()} roads`
: null,
]
.filter((part): part is string => part !== null)
.join(' · ');
}
if (clockLabel && stats) return `${clockLabel} · ${size} · ${stats}`;
if (clockLabel) return `${clockLabel} · ${size}`;
if (stats) return `${size} · ${stats}`;
return size;
/** The static facts about a world, for the hover text where there is room to spell them out. */
function worldTooltip(world: WorldSummary): string {
const climate = findClimate(climateOptions, world.climate ?? null);
return [
world.name,
climate ? describeClimate(climate) : null,
formatCoordinates(world.latitude, world.longitude, 4),
world.weather ? describeWeather(world.weather) : null,
]
.filter((line): line is string => line !== null)
.join('\n');
}
function applyClockToControls(clock: WorldClock): void {
@@ -101,6 +101,13 @@ describe('skyState', () => {
expect(skyState(time, WARSAW, weather({ condition: 'rain' }), false).hazeAlpha).toBe(0);
});
it('flags lightning only for a thunderstorm', () => {
const time = at(2012, 7, 15, 16);
expect(skyState(time, WARSAW, weather({ condition: 'thunderstorm' }), false).lightning).toBe(true);
expect(skyState(time, WARSAW, weather({ condition: 'heavyRain' }), false).lightning).toBe(false);
expect(skyState(time, WARSAW, weather({ condition: 'clear' }), false).lightning).toBe(false);
});
it('keeps every value inside its range across a whole year', () => {
for (let day = 1; day <= 365; day += 5) {
for (let hour = 0; hour < 24; hour += 2) {
@@ -160,6 +167,29 @@ describe('precipitationSpec', () => {
expect(gale.slantDeg).toBeLessThanOrEqual(62);
});
it('throws dust in a sandstorm, which carries no precipitation at all', () => {
const storm = precipitationSpec(
weather({ condition: 'sandstorm', precipitationMmH: 0, windSpeedMs: 14, windDirectionDeg: 270 }),
);
expect(storm.kind).toBe('dust');
expect(storm.density).toBeGreaterThan(0);
// Dust travels sideways, not down.
expect(Math.abs(storm.slantDeg)).toBeGreaterThan(70);
});
it('blows dust the way the wind is going', () => {
const westerly = precipitationSpec(
weather({ condition: 'sandstorm', windSpeedMs: 14, windDirectionDeg: 270 }),
);
const easterly = precipitationSpec(
weather({ condition: 'sandstorm', windSpeedMs: 14, windDirectionDeg: 90 }),
);
expect(westerly.slantDeg).toBeGreaterThan(0);
expect(easterly.slantDeg).toBeLessThan(0);
});
it('drops snow far more slowly than rain', () => {
const rain = precipitationSpec(weather({ precipitationMmH: 3, temperatureC: 9 }));
const snow = precipitationSpec(weather({ precipitationMmH: 3, temperatureC: -4 }));
+20 -6
View File
@@ -15,9 +15,11 @@ export interface SkyState {
hazeAlpha: number;
/** How thoroughly the ground is covered, 0..1. Drives the white over roofs and streets. */
snowCover: number;
/** True during a thunderstorm, which is the only thing that separates one from plain heavy rain. */
lightning: boolean;
}
export type PrecipitationKind = 'none' | 'rain' | 'snow';
export type PrecipitationKind = 'none' | 'rain' | 'snow' | 'dust';
export interface PrecipitationSpec {
kind: PrecipitationKind;
@@ -107,6 +109,7 @@ export function skyState(
tintAlpha: clamp01(alpha),
hazeAlpha: hazeFor(weather),
snowCover: clamp01(weather.snowDepthMm / FULL_SNOW_COVER_MM),
lightning: weather.condition === 'thunderstorm',
};
}
@@ -121,16 +124,27 @@ function hazeFor(weather: LocalWeather): number {
/** What is falling and how hard, ready for the particle layer. */
export function precipitationSpec(weather: LocalWeather): PrecipitationSpec {
// The wind blows towards the reverse of the bearing it comes from; on screen, north is up, so the
// east-west part of that is what tips the fall off vertical.
const towards = (weather.windDirectionDeg + 180) * (Math.PI / 180);
const drift = Math.sin(towards) * weather.windSpeedMs;
// A sandstorm carries no precipitation at all, so it has to be read off the condition rather than the
// rain gauge. What it throws about travels sideways, not down.
if (weather.condition === 'sandstorm') {
return {
kind: 'dust',
density: Math.min(120 + (weather.windSpeedMs * 22), 460),
slantDeg: drift >= 0 ? 80 : -80,
speedPxPerSecond: 120 + (weather.windSpeedMs * 26),
};
}
if (weather.precipitationMmH < PRECIPITATION_FLOOR_MMH) {
return { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
}
const snowing = weather.temperatureC < FREEZING_C;
// The wind blows towards the reverse of the bearing it comes from; on screen, north is up, so the
// east-west part of that is what tips the fall off vertical.
const towards = (weather.windDirectionDeg + 180) * (Math.PI / 180);
const drift = Math.sin(towards) * weather.windSpeedMs;
const slantDeg = clamp(drift * (snowing ? 1.8 : 3.2), -62, 62);
if (snowing) {
+77 -9
View File
@@ -7,6 +7,22 @@ const REFERENCE_AREA = 1280 * 720;
/** A hard ceiling on particles, whatever the screen size — the whole layer redraws every frame. */
const MAX_PARTICLES = 600;
const CLEAR_SKY: SkyState = {
sunElevationDeg: 90,
tint: 0xffffff,
tintAlpha: 0,
hazeAlpha: 0,
snowCover: 0,
lightning: false,
};
const NOTHING_FALLING: PrecipitationSpec = {
kind: 'none',
density: 0,
slantDeg: 0,
speedPxPerSecond: 0,
};
interface Particle {
x: number;
y: number;
@@ -28,17 +44,24 @@ export class WeatherLayer {
readonly precipitation = new Container();
private readonly wash = new Graphics();
private readonly flash = new Graphics();
private readonly drops = new Graphics();
private readonly particles: Particle[] = [];
private width = 0;
private height = 0;
private state: SkyState = { sunElevationDeg: 90, tint: 0xffffff, tintAlpha: 0, hazeAlpha: 0, snowCover: 0 };
private spec: PrecipitationSpec = { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
private state: SkyState = CLEAR_SKY;
private spec: PrecipitationSpec = NOTHING_FALLING;
private washDirty = true;
/** Seconds until the next strike, and how much of the current flash is left to burn off. */
private nextStrikeIn = 0;
private flashRemaining = 0;
private flashPeak = 0;
constructor() {
this.sky.addChild(this.wash);
this.sky.addChild(this.flash);
this.precipitation.addChild(this.drops);
this.sky.eventMode = 'none';
this.precipitation.eventMode = 'none';
@@ -76,6 +99,7 @@ export class WeatherLayer {
this.washDirty = false;
}
this.stepLightning(deltaMs);
this.stepParticles(deltaMs);
this.paintParticles();
}
@@ -83,9 +107,12 @@ export class WeatherLayer {
clear(): void {
this.particles.length = 0;
this.wash.clear();
this.flash.clear();
this.drops.clear();
this.state = { sunElevationDeg: 90, tint: 0xffffff, tintAlpha: 0, hazeAlpha: 0, snowCover: 0 };
this.spec = { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
this.state = CLEAR_SKY;
this.spec = NOTHING_FALLING;
this.nextStrikeIn = 0;
this.flashRemaining = 0;
}
destroy(): void {
@@ -113,6 +140,41 @@ export class WeatherLayer {
}
}
/**
* Strikes on a random gap of a few seconds and burns off over a fraction of one. Lightning is the only
* thing that tells a thunderstorm apart from heavy rain, and it is pure decoration - nothing in the
* simulation knows about it, so the randomness here is safe to leave unseeded.
*/
private stepLightning(deltaMs: number): void {
const seconds = deltaMs / 1000;
if (!this.state.lightning) {
this.flashRemaining = 0;
this.nextStrikeIn = 0;
this.flash.clear();
return;
}
if (this.flashRemaining > 0) {
this.flashRemaining -= seconds;
} else {
this.nextStrikeIn -= seconds;
if (this.nextStrikeIn <= 0) {
this.nextStrikeIn = 2 + (Math.random() * 6);
this.flashRemaining = 0.09 + (Math.random() * 0.08);
// A distant strike barely registers; a close one washes the whole screen out.
this.flashPeak = 0.12 + (Math.random() * 0.4);
}
}
this.flash.clear();
if (this.flashRemaining <= 0 || this.width === 0) return;
// Fade out over the tail of the flash rather than cutting it off.
const alpha = this.flashPeak * Math.min(this.flashRemaining / 0.09, 1);
this.flash.rect(0, 0, this.width, this.height).fill({ color: 0xf2f6ff, alpha });
}
/** Grows or trims the pool to the density the current weather asks for. */
private resizePool(): void {
const target = this.targetCount();
@@ -179,15 +241,21 @@ export class WeatherLayer {
return;
}
// One path for every drop, stroked once: Pixi batches the whole thing into a single draw.
// Rain and dust are both streaks; only their length and colour differ. One path for the lot, stroked
// once, so Pixi batches the whole thing into a single draw.
const dust = this.spec.kind === 'dust';
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
const length = 9 + (this.spec.speedPxPerSecond / 90);
const length = dust ? 4 : 9 + (this.spec.speedPxPerSecond / 90);
for (const particle of this.particles) {
const drop = length * particle.scale;
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (drop * slant), particle.y + drop);
const streak = length * particle.scale;
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (streak * slant), particle.y + streak);
}
this.drops.stroke({ width: 1.1, color: 0xaec6dd, alpha: 0.55 });
this.drops.stroke(
dust
? { width: 1.4, color: 0xc9a86a, alpha: 0.4 }
: { width: 1.1, color: 0xaec6dd, alpha: 0.55 },
);
}
}
@@ -43,6 +43,39 @@ public sealed class GameTimeTests
Assert.Equal(new DateTime(2012, 4, 24, 18, 0, 0, DateTimeKind.Unspecified), next);
}
[Fact]
public void LimitCatchUp_trims_a_step_to_the_game_time_it_is_allowed_to_bank()
{
var cap = TimeSpan.FromHours(24);
// At x1 a day of game time is 288 real seconds, so a week of downtime comes back trimmed to that.
var trimmed = GameTime.LimitCatchUp(TimeSpan.FromDays(7), timeScale: 1, cap);
Assert.Equal(288, trimmed.TotalSeconds, 1);
Assert.Equal(cap, GameTime.Advance(GameTime.DefaultStart, trimmed, 1, false) - GameTime.DefaultStart);
// Four times the speed banks the same day of game time in a quarter of the wall clock.
Assert.Equal(72, GameTime.LimitCatchUp(TimeSpan.FromDays(7), timeScale: 4, cap).TotalSeconds, 1);
}
[Fact]
public void LimitCatchUp_leaves_an_ordinary_tick_alone()
{
var tick = TimeSpan.FromMilliseconds(100);
Assert.Equal(tick, GameTime.LimitCatchUp(tick, 1, TimeSpan.FromHours(24)));
// Five seconds is what an idle world banks between lazy ticks; it must survive untouched too.
var lazy = TimeSpan.FromSeconds(5);
Assert.Equal(lazy, GameTime.LimitCatchUp(lazy, 4, TimeSpan.FromHours(24)));
}
[Fact]
public void LimitCatchUp_treats_a_non_positive_cap_as_no_cap()
{
var week = TimeSpan.FromDays(7);
Assert.Equal(week, GameTime.LimitCatchUp(week, 1, TimeSpan.Zero));
Assert.Equal(week, GameTime.LimitCatchUp(week, 1, TimeSpan.FromHours(-1)));
}
[Fact]
public void DefaultStart_is_morning_of_12_April_2012()
{
@@ -236,6 +236,21 @@ public sealed class WeatherModelTests
Assert.Equal(0f, WeatherModel.UpdateSnowDepth(-5f, -10f, 0f, 0f));
}
[Fact]
public void Lying_snow_thins_out_over_the_warmer_parts_of_the_map()
{
const float pack = 200f;
// Well below freezing the whole pack shows; the warmer corners of the field go patchy.
Assert.Equal(pack, WeatherModel.LocalSnowDepth(pack, -10f), 1);
Assert.True(WeatherModel.LocalSnowDepth(pack, 4f) < pack);
Assert.True(WeatherModel.LocalSnowDepth(pack, 12f) < WeatherModel.LocalSnowDepth(pack, 4f));
// It thins rather than vanishing - melting is the integral's job, not the renderer's.
Assert.True(WeatherModel.LocalSnowDepth(pack, 30f) > 0f);
Assert.Equal(0f, WeatherModel.LocalSnowDepth(0f, -10f));
}
[Fact]
public void A_world_opened_in_deep_winter_already_has_snow_on_the_ground()
{
@@ -140,7 +140,10 @@ public sealed class WorldGenerationServiceTests : IDisposable
generator,
_store,
new ChunkExporter(),
new WorldSimulationHost(_store, NullLogger<WorldSimulationHost>.Instance),
new WorldSimulationHost(
_store,
Options.Create(new SimulationOptions()),
NullLogger<WorldSimulationHost>.Instance),
Options.Create(new WorldStorageOptions
{
RootDirectory = _root,
@@ -150,7 +153,7 @@ public sealed class WorldGenerationServiceTests : IDisposable
NullLogger<WorldGenerationService>.Instance);
}
private static WorldSummaryDto Summary(string id, string name) => new()
private static StoredWorldDto Summary(string id, string name) => new()
{
Id = id,
Name = name,
@@ -18,7 +18,10 @@ public sealed class WorldSimulationHostTests : IDisposable
_store = new WorldStore(
Options.Create(new WorldStorageOptions { RootDirectory = _root }),
NullLogger<WorldStore>.Instance);
_host = new WorldSimulationHost(_store, NullLogger<WorldSimulationHost>.Instance);
_host = new WorldSimulationHost(
_store,
Options.Create(new SimulationOptions()),
NullLogger<WorldSimulationHost>.Instance);
}
[Fact]
@@ -100,26 +103,61 @@ public sealed class WorldSimulationHostTests : IDisposable
Assert.Null(_host.TryGetClock(summary.Id));
}
/// <summary>
/// Storage bookkeeping cannot leak here by construction - <see cref="WorldSummaryDto"/> has no field to
/// hold it - so what is left to check is that the facts survive the projection and the live values land.
/// </summary>
[Fact]
public void Overlay_strips_last_ticked_at_for_api()
public void Overlay_carries_the_stored_facts_and_the_live_clock()
{
var summary = Summary("ready-33333333", "Ready");
_host.Attach(summary);
var overlaid = _host.Overlay(summary);
Assert.Equal(summary.Id, overlaid.Id);
Assert.Equal(summary.Name, overlaid.Name);
Assert.Equal(summary.SizeMeters, overlaid.SizeMeters);
Assert.Equal(summary.CreatedAt, overlaid.CreatedAt);
Assert.NotNull(overlaid.Clock);
Assert.Null(overlaid.LastTickedAt);
Assert.NotNull(overlaid.Weather);
}
[Fact]
public void Overlay_strips_last_ticked_at_for_worlds_that_are_not_running()
public void Overlay_falls_back_to_the_stored_facts_for_a_world_that_is_not_running()
{
var overlaid = _host.Overlay(Summary("pending-66666666", "Pending") with
{
Status = WorldStatus.Pending,
});
var pending = Summary("pending-66666666", "Pending") with { Status = WorldStatus.Pending };
Assert.Null(overlaid.LastTickedAt);
var overlaid = _host.Overlay(pending);
Assert.Equal(WorldStatus.Pending, overlaid.Status);
Assert.Equal(pending.Name, overlaid.Name);
// No simulation is running, so there is no live weather to lay over it.
Assert.Null(overlaid.Weather);
}
[Fact]
public async Task Reading_a_lazily_ticked_world_reports_the_current_time_not_a_stale_one()
{
var summary = Summary("lazy-88888888", "Lazy");
await _store.SaveSummaryAsync(summary);
_host.Attach(summary);
var before = _host.TryGetClock(summary.Id);
Assert.NotNull(before);
// Nothing ticks it in between; the read itself has to do the skipped work.
await Task.Delay(400);
var after = _host.TryGetClock(summary.Id);
Assert.NotNull(after);
Assert.True(
after.GameTime > before.GameTime,
"A read must bring the world current rather than answering from the last lazy tick.");
// 400 ms at x1 is 2 game minutes; allow for scheduling slop either side.
var advanced = after.GameTime - before.GameTime;
Assert.InRange(advanced, TimeSpan.FromMinutes(1.5), TimeSpan.FromMinutes(6));
}
/// <summary>
@@ -156,7 +194,7 @@ public sealed class WorldSimulationHostTests : IDisposable
throw new TimeoutException($"Simulation for world '{id}' never attached.");
}
private static WorldSummaryDto Summary(string id, string name) => new()
private static StoredWorldDto Summary(string id, string name) => new()
{
Id = id,
Name = name,
@@ -175,7 +213,7 @@ public sealed class WorldSimulationHostTests : IDisposable
string[] ids =
[
"legacy-11111111", "ready-22222222", "ready-33333333",
"racy-44444444", "stale-55555555", "pending-66666666", "doomed-77777777",
"racy-44444444", "stale-55555555", "pending-66666666", "doomed-77777777", "lazy-88888888",
];
foreach (var id in ids)
@@ -100,15 +100,12 @@ public sealed class WorldSimulationTests
}
[Fact]
public void OverlayForApi_strips_storage_only_state_and_adds_live_weather()
public void OverlayForApi_projects_the_stored_world_and_adds_live_weather()
{
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
var overlaid = simulation.OverlayForApi(ReadySummary());
Assert.NotNull(overlaid.Clock);
Assert.Null(overlaid.LastTickedAt);
Assert.Null(overlaid.WeatherState);
Assert.Equal(ClimateKind.CentralEuropean, overlaid.Climate);
Assert.NotNull(overlaid.Weather);
Assert.InRange(overlaid.Weather.Humidity, 0, 1);
@@ -137,8 +134,25 @@ public sealed class WorldSimulationTests
Assert.NotNull(stored.WeatherState);
Assert.NotEmpty(stored.WeatherState.Systems);
Assert.NotEqual(0ul, stored.WeatherState.RngState);
// Weather itself is derived, so it has no business in the file.
Assert.Null(stored.Weather);
}
/// <summary>
/// The projection is one-way on purpose: the storage type carries the bookkeeping, the wire type has no
/// field that could hold it, and derived weather never reaches the file.
/// </summary>
[Fact]
public void The_stored_shape_and_the_wire_shape_carry_different_things()
{
var storedFields = typeof(StoredWorldDto).GetProperties().Select(static p => p.Name).ToHashSet();
var wireFields = typeof(WorldSummaryDto).GetProperties().Select(static p => p.Name).ToHashSet();
Assert.Contains("LastTickedAt", storedFields);
Assert.Contains("WeatherState", storedFields);
Assert.DoesNotContain("LastTickedAt", wireFields);
Assert.DoesNotContain("WeatherState", wireFields);
Assert.Contains("Weather", wireFields);
Assert.DoesNotContain("Weather", storedFields);
}
[Fact]
@@ -157,6 +171,61 @@ public sealed class WorldSimulationTests
Assert.Equal(weatherBefore.Condition, weatherAfter.Condition);
}
[Fact]
public void A_week_of_downtime_only_costs_the_world_the_capped_amount_of_game_time()
{
var summary = ReadySummary() with { LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(7) };
var options = new SimulationOptions { MaxCatchUpGameHours = 24 };
using var simulation = WorldSimulation.Create(summary, catchUp: true, options);
// Uncapped this would be about six game years; the world wakes up one day older instead.
var advanced = simulation.SnapshotClock().GameTime - GameTime.DefaultStart;
Assert.InRange(advanced, TimeSpan.FromHours(24), TimeSpan.FromHours(24.2));
}
[Fact]
public void A_zero_cap_means_the_world_replays_everything_it_missed()
{
var summary = ReadySummary() with { LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromHours(1) };
var options = new SimulationOptions { MaxCatchUpGameHours = 0 };
using var simulation = WorldSimulation.Create(summary, catchUp: true, options);
// One real hour at x1 is 12.5 game days, and nothing trims it.
var advanced = simulation.SnapshotClock().GameTime - GameTime.DefaultStart;
Assert.InRange(advanced, TimeSpan.FromDays(12), TimeSpan.FromDays(13));
}
[Fact]
public void A_world_is_watched_when_it_attaches_and_goes_idle_once_nobody_asks()
{
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
var now = DateTimeOffset.UtcNow;
Assert.False(simulation.IsIdle(now, TimeSpan.FromSeconds(20)));
Assert.True(simulation.IsIdle(now + TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(20)));
simulation.Touch();
Assert.False(simulation.IsIdle(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(20)));
// A zero threshold turns the whole idea off: everything stays watched.
Assert.False(simulation.IsIdle(now + TimeSpan.FromDays(1), TimeSpan.Zero));
}
[Fact]
public void Ticking_lazily_lands_on_the_same_game_time_as_ticking_often()
{
using var lazy = WorldSimulation.Create(ReadySummary(), catchUp: false);
using var eager = WorldSimulation.Create(ReadySummary(), catchUp: false);
lazy.Tick(TimeSpan.FromSeconds(5));
for (var i = 0; i < 50; i++) eager.Tick(TimeSpan.FromMilliseconds(100));
// The tick rate is a cost decision, not a correctness one: a step is driven by elapsed wall time.
Assert.Equal(lazy.SnapshotClock().GameTime, eager.SnapshotClock().GameTime);
}
[Fact]
public void A_long_absence_rolls_a_fresh_sky_instead_of_stepping_through_it()
{
@@ -209,7 +278,7 @@ public sealed class WorldSimulationTests
}
}
private static WorldSummaryDto ReadySummary() => new()
private static StoredWorldDto ReadySummary() => new()
{
Id = "town-aaaaaaaa",
Name = "Town",
@@ -80,7 +80,7 @@ public sealed class WorldStoreTests : IDisposable
Assert.Equal(["state.json"], files.Select(static path => Path.GetFileName(path)).Order().ToArray()!);
}
private static WorldSummaryDto Summary(string id, string name) => new()
private static StoredWorldDto Summary(string id, string name) => new()
{
Id = id,
Name = name,