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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
// 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;
|
||||
: 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 }));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user