diff --git a/AGENTS.md b/AGENTS.md
index 24962c4..c486600 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -66,7 +66,13 @@ The Living World is a web game whose map is a real place: the API imports OpenSt
Worlds are stored as files under `data/` (not committed). Cap: `WorldStorage:MaxConcurrentWorlds`.
Ready worlds run a live game clock on the server (5 game minutes per real second at x1; default start
-12 April 2012 06:00). UI: main menu (list + create) → map screen with pause and speed controls.
+12 April 2012 06:00) plus weather: a Köppen-lite climate preset per world, with drifting pressure systems as
+ECS entities on top of a deterministic seasonal/diurnal baseline. UI: main menu (list + create, with a climate
+picker) → map screen with pause, speed, clock and weather, and a renderer that washes the map for time of
+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.
Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
diff --git a/README.md b/README.md
index 674b521..72f56f0 100644
--- a/README.md
+++ b/README.md
@@ -108,14 +108,42 @@ them without reworking the data model.
| `GET /api/worlds/{id}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index |
| `GET /api/worlds/{id}/chunks/{x}/{y}` | One chunk of geometry |
+| `GET /api/worlds/{id}/weather` | The weather field over the map: an 8×8 grid of samples, row-major from the south-west corner |
| `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 1–4). Body: `{ paused?, timeScale? }` |
| `DELETE /api/worlds/{id}` | Remove a world and its chunks |
+| `GET /api/climates` | The climate catalogue for the create form, with the latitude band each preset is the default for |
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
folders on disk; later the same budget will limit concurrent simulation).
+### Climate and weather
+
+Each world picks one of twelve Köppen-lite climates at creation. Leave it out and the server guesses from the
+latitude; the create form previews that guess using the band limits `GET /api/climates` returns, so the rule
+lives in exactly one place. Three presets — tropical monsoon, cold steppe and highland — depend on
+continentality or altitude rather than latitude, so they are never guessed and have to be chosen.
+
+Weather is a hybrid: the climate gives a deterministic baseline (seasonal curve, daily curve, wet season),
+and a handful of pressure systems drift across the map on top of it as ECS entities, fading in and out. Cloud,
+rain, wind and the apparent temperature all fall out of that field, which is why a front visibly crosses the
+map instead of the whole world flipping from sunny to wet at once. Systems drift at a fixed rate in normalised
+world space rather than a real one: a genuine front crosses ten kilometres in minutes, which at five game
+minutes per real second would be a flicker.
+
+The drifting systems are persisted in `state.json` so a restart resumes the sky it had. Come back after more
+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.
+
+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
+January starts under snow rather than waiting for the first fall.
+
+`GET /api/worlds/{id}` carries the weather at the middle of the map for the HUD; the full grid is a separate
+call, because the world list would otherwise haul sixty-four samples per world on every poll.
+
Geometry travels as flat `[x0, y0, x1, y1, …]` arrays of world metres, which is exactly what PixiJS
`Graphics.poly()` accepts, so the client never reshapes it. Responses are compressed; chunk files are written
in wire format and streamed straight from disk.
@@ -151,6 +179,18 @@ floor so hairlines stay visible. At street level the map picks up the things tha
- gentle bends in roads and watercourses are rounded off by Chaikin corner cutting; corners sharper than 50°
are left alone, because a gridded town is full of genuine right angles
+`WeatherLayer` sits over the map in screen space, so the weather does not slide about when you pan. Below the
+place names goes a wash: a colour for the time of day, interpolated from the sun's elevation through golden
+hour, dusk and night, greyed down by cloud while the sun is up; then white for lying snow; then a pale haze
+for fog, blizzards and sandstorms. Above the names falls the precipitation — slanted streaks for rain,
+drifting dots for snow — leaning downwind at a slant taken from the local wind and capped so a gale still
+looks like weather rather than a barcode. The whole thing is read from the server grid under the middle of
+the screen, so panning towards a front walks into the rain.
+
+The maths lives in `sky.ts` and `weatherField.ts`, which import no PixiJS and are unit-tested; `weatherLayer.ts`
+only knows how to paint the result. A dark theme pulls the night wash back rather than switching it off,
+because the map is already drawn dark and dusk still has to feel like dusk.
+
Place names are drawn in screen space so text keeps a constant size at every zoom, and the work is split in
two. `labelPlacement.ts` decides *which* names to show: candidates are ranked — water bodies first, then
arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a
diff --git a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs
index dd5d4b9..6d00b5f 100644
--- a/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs
+++ b/src/TheLivingWorld.Api/Endpoints/WorldEndpoints.cs
@@ -17,12 +17,27 @@ public static class WorldEndpoints
worlds.MapGet("/{id}", GetWorld);
worlds.MapGet("/{id}/map", GetMap);
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
+ worlds.MapGet("/{id}/weather", GetWeather);
worlds.MapPatch("/{id}/clock", UpdateClock);
worlds.MapDelete("/{id}", DeleteWorld);
+ app.MapGet("/api/climates", ListClimates).WithTags("worlds");
+
return app;
}
+ /// The climate picker's source of truth, so the create form cannot drift from the catalogue.
+ private static IResult ListClimates() => Results.Ok(ClimateCatalog.All
+ .Select(static preset => new ClimateDto
+ {
+ Kind = preset.Kind,
+ Label = preset.Label,
+ KoppenCode = preset.KoppenCode,
+ Example = preset.Example,
+ BandLimit = ClimateCatalog.BandLimit(preset.Kind),
+ })
+ .ToArray());
+
private static async Task ListWorlds(
WorldStore store,
WorldGenerationService generation,
@@ -113,6 +128,31 @@ public static class WorldEndpoints
return Results.Stream(stream, "application/json");
}
+ private static async Task GetWeather(
+ string id,
+ WorldStore store,
+ WorldSimulationHost simulation,
+ CancellationToken cancellationToken)
+ {
+ if (!WorldStore.IsValidId(id)) return Results.NotFound();
+
+ // 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);
+
+ var summary = await store.GetSummaryAsync(id, cancellationToken);
+ if (summary is null) return Results.NotFound();
+
+ if (!simulation.IsAttached(id)) simulation.Attach(summary);
+
+ return simulation.TryGetWeatherField(id) is { } attached
+ ? Results.Ok(attached)
+ : Results.ValidationProblem(new Dictionary
+ {
+ ["id"] = ["Weather is only available when the world is ready."],
+ });
+ }
+
private static async Task UpdateClock(
string id,
UpdateClockRequest request,
diff --git a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs
index b5b1cf5..0bf8bd3 100644
--- a/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs
+++ b/src/TheLivingWorld.Api/Generation/WorldGenerationService.cs
@@ -57,6 +57,10 @@ public sealed class WorldGenerationService(
throw new ArgumentException(ex.Message, ex);
}
+ var climate = request.Climate ?? ClimateCatalog.FromLatitude(origin.Latitude);
+ if (!Enum.IsDefined(climate))
+ throw new ArgumentException($"'{request.Climate}' is not a known climate.");
+
var name = string.IsNullOrWhiteSpace(request.Name)
? $"{origin.Latitude:F4}, {origin.Longitude:F4}"
: request.Name.Trim();
@@ -73,6 +77,7 @@ public sealed class WorldGenerationService(
Stage = "Queued",
CreatedAt = DateTimeOffset.UtcNow,
Clock = WorldSimulation.DefaultClock(startGameTime),
+ Climate = climate,
};
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs
index 0f7ee28..1abaadb 100644
--- a/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs
+++ b/src/TheLivingWorld.Api/Simulation/WorldSimulation.cs
@@ -6,28 +6,46 @@ using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Api.Simulation;
///
-/// Lightweight live runtime for one world: an Arch world holding a single entity.
-/// Map geometry stays on disk; only the clock (and later sim state) lives here.
+/// Lightweight live runtime for one world: an Arch world holding the entity and the
+/// pressure systems that drive its weather. Map geometry stays on disk; only simulation state lives here.
///
public sealed class WorldSimulation : IDisposable
{
+ ///
+ /// 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.
+ ///
+ private static readonly TimeSpan MaxWeatherCatchUp = TimeSpan.FromHours(24);
+
private readonly object _gate = new();
private readonly World _ecs;
private readonly Entity _clockEntity;
+ private readonly ClimatePreset _climate;
+ private readonly double _latitude;
private DateTimeOffset _lastTickedAt;
private bool _dirty;
private bool _disposed;
- private WorldSimulation(string worldId, World ecs, Entity clockEntity, DateTimeOffset lastTickedAt)
+ private WorldSimulation(
+ string worldId,
+ World ecs,
+ Entity clockEntity,
+ ClimatePreset climate,
+ double latitude,
+ DateTimeOffset lastTickedAt)
{
WorldId = worldId;
_ecs = ecs;
_clockEntity = clockEntity;
+ _climate = climate;
+ _latitude = latitude;
_lastTickedAt = lastTickedAt;
}
public string WorldId { get; }
+ public ClimateKind Climate => _climate.Kind;
+
public bool IsDirty
{
get
@@ -43,16 +61,20 @@ public sealed class WorldSimulation : IDisposable
public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true)
{
ArgumentNullException.ThrowIfNull(summary);
+ SimulationComponents.EnsureRegistered();
var clock = summary.Clock ?? DefaultClock();
var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale;
var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified);
+ var climate = ClimateCatalog.Get(summary.Climate ?? ClimateCatalog.FromLatitude(summary.Latitude));
+
var ecs = World.Create();
var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused));
- var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
+ RestoreWeather(ecs, summary, climate, gameTime);
- var simulation = new WorldSimulation(summary.Id, ecs, entity, lastTickedAt);
+ var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
+ var simulation = new WorldSimulation(summary.Id, ecs, entity, climate, summary.Latitude, lastTickedAt);
if (catchUp && !clock.Paused)
{
@@ -68,6 +90,75 @@ public sealed class WorldSimulation : IDisposable
return simulation;
}
+ private static void RestoreWeather(
+ World ecs,
+ WorldSummaryDto summary,
+ ClimatePreset climate,
+ DateTime gameTime)
+ {
+ var fallbackSeed = DeterministicRandom.SeedFrom(summary.Id);
+ var stored = summary.WeatherState;
+
+ if (stored is null || stored.Systems.Count == 0)
+ {
+ WeatherSystem.Seed(ecs, climate, summary.Latitude, fallbackSeed, gameTime);
+ return;
+ }
+
+ Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
+ var count = Math.Min(stored.Systems.Count, WeatherSystem.MaxSystems);
+
+ for (var i = 0; i < count; i++)
+ {
+ var dto = stored.Systems[i];
+ systems[i] = new PressureSystem(
+ dto.X, dto.Y,
+ dto.VelocityX, dto.VelocityY,
+ dto.IntensityHpa, dto.Radius,
+ dto.AgeHours, dto.LifetimeHours);
+ }
+
+ WeatherSystem.Restore(
+ ecs,
+ climate,
+ summary.Latitude,
+ fallbackSeed,
+ gameTime,
+ stored.RngState,
+ stored.SnowDepthMm,
+ systems[..count]);
+ }
+
+ private WeatherStateDto SnapshotWeatherStateUnlocked()
+ {
+ Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
+ var count = WeatherSystem.CopySystems(_ecs, systems);
+
+ var stored = new PressureSystemDto[count];
+ for (var i = 0; i < count; i++)
+ {
+ var system = systems[i];
+ stored[i] = new PressureSystemDto
+ {
+ X = system.X,
+ Y = system.Y,
+ VelocityX = system.VelocityX,
+ VelocityY = system.VelocityY,
+ IntensityHpa = system.IntensityHpa,
+ Radius = system.Radius,
+ AgeHours = system.AgeHours,
+ LifetimeHours = system.LifetimeHours,
+ };
+ }
+
+ return new WeatherStateDto
+ {
+ RngState = WeatherSystem.RngState(_ecs),
+ SnowDepthMm = WeatherSystem.SnowDepthMm(_ecs),
+ Systems = stored,
+ };
+ }
+
public static WorldClockDto DefaultClock(DateTime? startGameTime = null) => new()
{
GameTime = GameTime.ResolveStart(startGameTime),
@@ -81,19 +172,55 @@ public sealed class WorldSimulation : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
- if (realElapsed > TimeSpan.Zero)
- {
- // Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
- // allocate for nothing.
- var before = _ecs.Get(_clockEntity).Ticks;
- ClockSystem.Execute(_ecs, realElapsed);
- if (_ecs.Get(_clockEntity).Ticks != before) _dirty = true;
- }
+ if (realElapsed > TimeSpan.Zero && AdvanceUnlocked(realElapsed)) _dirty = true;
_lastTickedAt = DateTimeOffset.UtcNow;
}
}
+ ///
+ /// Runs the clock and then the weather for one slice of wall time. Returns whether game time actually
+ /// moved, which is false whenever the world is paused.
+ ///
+ private bool AdvanceUnlocked(TimeSpan realElapsed)
+ {
+ // Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
+ // allocate for nothing.
+ var before = _ecs.Get(_clockEntity).Ticks;
+ ClockSystem.Execute(_ecs, realElapsed);
+ var elapsedGameTicks = _ecs.Get(_clockEntity).Ticks - before;
+ if (elapsedGameTicks <= 0) return false;
+
+ var elapsedGame = TimeSpan.FromTicks(elapsedGameTicks);
+ var gameTime = new DateTime(_ecs.Get(_clockEntity).Ticks, DateTimeKind.Unspecified);
+
+ if (elapsedGame > MaxWeatherCatchUp)
+ {
+ // 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.
+ WeatherSystem.Reseed(_ecs, _climate, _latitude, gameTime);
+ return true;
+ }
+
+ var hours = (float)elapsedGame.TotalHours;
+ WeatherSystem.Execute(_ecs, _climate, _latitude, hours);
+
+ // Snow lies on the ground, so it has to be integrated as the sky moves rather than derived from the
+ // instant. The middle of the map speaks for all of it; over ten kilometres that is no lie worth care.
+ var overhead = SampleRawUnlocked(0.5f, 0.5f, gameTime);
+ WeatherSystem.AccumulateSnow(_ecs, overhead.TemperatureC, overhead.PrecipitationMmH, hours);
+
+ return true;
+ }
+
+ private WeatherSample SampleRawUnlocked(float x, float y, DateTime gameTime)
+ {
+ Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
+ var count = WeatherSystem.CopySystems(_ecs, systems);
+ var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems[..count], x, y);
+ return WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY);
+ }
+
public WorldClockDto SnapshotClock()
{
lock (_gate)
@@ -125,7 +252,7 @@ public sealed class WorldSimulation : IDisposable
var now = DateTimeOffset.UtcNow;
var gap = now - _lastTickedAt;
- if (gap > TimeSpan.Zero) ClockSystem.Execute(_ecs, gap);
+ if (gap > TimeSpan.Zero) AdvanceUnlocked(gap);
ref var clock = ref _ecs.Get(_clockEntity);
@@ -145,6 +272,77 @@ public sealed class WorldSimulation : IDisposable
}
}
+ /// Nodes per side of the weather grid served to the renderer.
+ public const int WeatherGridSize = 8;
+
+ /// Weather at the middle of the map - what the HUD and the world list show.
+ public WeatherDto SnapshotWeather()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
+ var count = WeatherSystem.CopySystems(_ecs, systems);
+ return SampleUnlocked(systems[..count], 0.5f, 0.5f);
+ }
+ }
+
+ ///
+ /// The whole weather field as a square grid, row-major from the south-west corner. Sampled in one pass so
+ /// every node sees the same instant and the same pressure systems.
+ ///
+ public WeatherFieldDto SnapshotWeatherField()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
+ var count = WeatherSystem.CopySystems(_ecs, systems);
+ var live = systems[..count];
+
+ var nodes = new WeatherDto[WeatherGridSize * WeatherGridSize];
+ for (var row = 0; row < WeatherGridSize; row++)
+ {
+ for (var column = 0; column < WeatherGridSize; column++)
+ {
+ var x = column / (float)(WeatherGridSize - 1);
+ var y = row / (float)(WeatherGridSize - 1);
+ nodes[(row * WeatherGridSize) + column] = SampleUnlocked(live, x, y);
+ }
+ }
+
+ return new WeatherFieldDto
+ {
+ Climate = _climate.Kind,
+ Size = WeatherGridSize,
+ Nodes = nodes,
+ };
+ }
+ }
+
+ private WeatherDto SampleUnlocked(ReadOnlySpan systems, float x, float y)
+ {
+ var gameTime = new DateTime(_ecs.Get(_clockEntity).Ticks, DateTimeKind.Unspecified);
+ var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems, x, y);
+ var sample = WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY);
+
+ return new WeatherDto
+ {
+ SnowDepthMm = Math.Round(WeatherSystem.SnowDepthMm(_ecs), 1),
+ Condition = sample.Condition,
+ TemperatureC = Math.Round(sample.TemperatureC, 1),
+ FeelsLikeC = Math.Round(sample.FeelsLikeC, 1),
+ PressureHpa = Math.Round(sample.PressureHpa, 1),
+ Humidity = Math.Round(sample.Humidity, 3),
+ CloudCover = Math.Round(sample.CloudCover, 3),
+ PrecipitationMmH = Math.Round(sample.PrecipitationMmH, 2),
+ WindSpeedMs = Math.Round(sample.WindSpeedMs, 1),
+ WindDirectionDeg = Math.Round(sample.WindDirectionDeg, 0),
+ };
+ }
+
public WorldSummaryDto ApplyTo(WorldSummaryDto summary)
{
lock (_gate)
@@ -153,21 +351,30 @@ public sealed class WorldSimulation : IDisposable
return summary with
{
Clock = SnapshotClockUnlocked(),
+ Climate = _climate.Kind,
LastTickedAt = _lastTickedAt,
+ WeatherState = SnapshotWeatherStateUnlocked(),
};
}
}
- /// Wire-facing snapshot: live clock, no internal last-tick stamp.
+ /// Wire-facing snapshot: live clock and weather, none of the storage-only bookkeeping.
public WorldSummaryDto OverlayForApi(WorldSummaryDto summary)
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
+
+ Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
+ var count = WeatherSystem.CopySystems(_ecs, systems);
+
return summary with
{
Clock = SnapshotClockUnlocked(),
+ Climate = _climate.Kind,
+ Weather = SampleUnlocked(systems[..count], 0.5f, 0.5f),
LastTickedAt = null,
+ WeatherState = null,
};
}
}
diff --git a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs
index 6161b8f..5ccde2e 100644
--- a/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs
+++ b/src/TheLivingWorld.Api/Simulation/WorldSimulationHost.cs
@@ -44,14 +44,18 @@ public sealed class WorldSimulationHost(
_simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotClock() : null;
///
- /// The one projection from stored/in-flight state to what clients see: overlays the live clock when the
- /// world is running and always drops , which is storage-only.
+ /// 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
+ /// (, ).
/// Every endpoint that returns a summary must go through here.
///
public WorldSummaryDto Overlay(WorldSummaryDto summary) =>
_simulations.TryGetValue(summary.Id, out var simulation)
? simulation.OverlayForApi(summary)
- : summary with { LastTickedAt = null };
+ : summary with { LastTickedAt = null, WeatherState = null };
+
+ public WeatherFieldDto? TryGetWeatherField(string id) =>
+ _simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotWeatherField() : null;
public WorldClockDto UpdateClock(string id, UpdateClockRequest request)
{
diff --git a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs
index 921f097..95f4dc7 100644
--- a/src/TheLivingWorld.Core/Contracts/WorldContracts.cs
+++ b/src/TheLivingWorld.Core/Contracts/WorldContracts.cs
@@ -1,3 +1,5 @@
+using TheLivingWorld.Core.Simulation;
+
namespace TheLivingWorld.Core.Contracts;
public enum WorldStatus
@@ -27,6 +29,12 @@ public sealed record CreateWorldRequest
/// Naive in-world calendar start. When omitted, defaults to 12 April 2012 06:00.
///
public DateTime? StartGameTime { get; init; }
+
+ ///
+ /// Climate driving the weather. When omitted it is guessed from , which is right
+ /// often enough to be a good default and wrong often enough to stay overridable.
+ ///
+ public ClimateKind? Climate { get; init; }
}
/// Response body for GET /api/worlds.
@@ -65,11 +73,61 @@ public sealed record WorldSummaryDto
/// In-world calendar and playback controls. Present once the world exists; frozen until Ready.
public WorldClockDto? Clock { get; init; }
+ /// Climate driving this world's weather. Null only for worlds created before climates existed.
+ public ClimateKind? Climate { get; init; }
+
+ ///
+ /// Live weather at the centre of the map, for the HUD and the world list. The full field lives behind
+ /// GET /api/worlds/{id}/weather; only Ready worlds that are actually running carry this.
+ ///
+ public WeatherDto? Weather { get; init; }
+
///
/// Wall-clock moment of the last simulation tick. Persisted in state.json for catch-up after
/// restart; stripped from API responses (clients see live only).
///
public DateTimeOffset? LastTickedAt { get; init; }
+
+ ///
+ /// 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 .
+ ///
+ public WeatherStateDto? WeatherState { get; init; }
+}
+
+/// Storage shape of a world's live weather. Never leaves the server.
+public sealed record WeatherStateDto
+{
+ /// PRNG state, so respawned systems continue the world's sequence rather than restarting it.
+ public required ulong RngState { get; init; }
+
+ /// Lying snow. Has to be stored: it is the accumulated past, not a function of the present.
+ public float SnowDepthMm { get; init; }
+
+ public required IReadOnlyList Systems { get; init; }
+}
+
+///
+/// One pressure system on disk. Deliberately not the ECS component itself: the component is free to change
+/// shape for the simulation's convenience without breaking every world already written to state.json.
+///
+public sealed record PressureSystemDto
+{
+ public required float X { get; init; }
+
+ public required float Y { get; init; }
+
+ public required float VelocityX { get; init; }
+
+ public required float VelocityY { get; init; }
+
+ public required float IntensityHpa { get; init; }
+
+ public required float Radius { get; init; }
+
+ public required float AgeHours { get; init; }
+
+ public required float LifetimeHours { get; init; }
}
/// Live game calendar for a world. Game time is naive local calendar time, not UTC.
@@ -83,6 +141,72 @@ public sealed record WorldClockDto
public required bool Paused { get; init; }
}
+/// Weather at one point. Values are rounded on the way out - nobody needs 14 digits of humidity.
+public sealed record WeatherDto
+{
+ public required WeatherCondition Condition { get; init; }
+
+ public required double TemperatureC { get; init; }
+
+ /// Wind chill in the cold, humidex in the heat, plain temperature in between.
+ public required double FeelsLikeC { get; init; }
+
+ public required double PressureHpa { get; init; }
+
+ /// Relative humidity, 0..1.
+ public required double Humidity { get; init; }
+
+ /// Fraction of sky covered, 0..1. Drives the overcast tint in the renderer.
+ public required double CloudCover { get; init; }
+
+ public required double PrecipitationMmH { get; init; }
+
+ public required double WindSpeedMs { get; init; }
+
+ /// Compass bearing the wind blows from, 0..360.
+ public required double WindDirectionDeg { get; init; }
+
+ ///
+ /// 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.
+ ///
+ public required double SnowDepthMm { get; init; }
+}
+
+///
+/// Response body for GET /api/worlds/{id}/weather: the weather field over the map as a square grid of
+/// samples, row-major from the south-west corner. Coarse on purpose - the client interpolates between nodes.
+///
+public sealed record WeatherFieldDto
+{
+ public required ClimateKind Climate { get; init; }
+
+ /// Nodes per side. Nodes holds Size * Size entries.
+ public required int Size { get; init; }
+
+ public required IReadOnlyList Nodes { get; init; }
+}
+
+/// One entry of GET /api/climates, so the create form never drifts from the server's list.
+public sealed record ClimateDto
+{
+ public required ClimateKind Kind { get; init; }
+
+ public required string Label { get; init; }
+
+ /// Köppen code, e.g. Dfb.
+ public required string KoppenCode { get; init; }
+
+ /// A real place that feels like this.
+ public required string Example { get; init; }
+
+ ///
+ /// Absolute latitude below which this preset is the default, or null when the latitude rule never picks
+ /// it. The create form previews the server's guess from these bounds instead of duplicating the table.
+ ///
+ public double? BandLimit { get; init; }
+}
+
/// Request body for PATCH /api/worlds/{id}/clock. Omitted fields keep their current value.
public sealed record UpdateClockRequest
{
diff --git a/src/TheLivingWorld.Core/Ecs/Components.cs b/src/TheLivingWorld.Core/Ecs/Components.cs
index dc618c8..c3b3933 100644
--- a/src/TheLivingWorld.Core/Ecs/Components.cs
+++ b/src/TheLivingWorld.Core/Ecs/Components.cs
@@ -36,3 +36,25 @@ public record struct DisplayName(string? Value);
/// naive (unspecified) calendar — local morning in the town, not UTC.
///
public record struct GameClock(long Ticks, int TimeScale, bool Paused);
+
+///
+/// One drifting cyclone (negative ) or anticyclone (positive). Position and radius
+/// are in normalised world space, where the map spans 0..1 on both axes and +Y points north; systems live
+/// outside that box while they blow in and out. Summing these is what makes a front cross the map.
+///
+public record struct PressureSystem(
+ float X,
+ float Y,
+ float VelocityX,
+ float VelocityY,
+ float IntensityHpa,
+ float Radius,
+ float AgeHours,
+ float LifetimeHours);
+
+///
+/// Singleton weather bookkeeping for a live world: the seeded PRNG that spawns pressure systems, kept in the
+/// world so a restart resumes the same sequence instead of re-rolling the sky, and the snow lying on the
+/// ground, which is the one part of the weather that cannot be derived from the current instant.
+///
+public record struct WeatherState(ulong RngState, float SnowDepthMm);
diff --git a/src/TheLivingWorld.Core/Simulation/ClimateKind.cs b/src/TheLivingWorld.Core/Simulation/ClimateKind.cs
new file mode 100644
index 0000000..dad0186
--- /dev/null
+++ b/src/TheLivingWorld.Core/Simulation/ClimateKind.cs
@@ -0,0 +1,63 @@
+namespace TheLivingWorld.Core.Simulation;
+
+///
+/// Köppen-lite climate presets. Twelve buckets that cover every recognisable place on Earth without asking a
+/// player to pick from thirty classes.
+///
+public enum ClimateKind
+{
+ /// Af - rain all year, barely any seasons. Singapore.
+ Equatorial = 0,
+
+ /// Am - a hot dry season broken by a violent wet one. Mumbai.
+ TropicalMonsoon,
+
+ /// Aw - savanna: warm year round, rain in summer only. Nairobi.
+ Savanna,
+
+ /// BWh - hot desert: enormous day/night swing, almost no rain. Cairo.
+ HotDesert,
+
+ /// BSk - cold steppe: dry, continental, windy. Astana.
+ ColdSteppe,
+
+ /// Csa - dry hot summer, mild wet winter. Barcelona.
+ Mediterranean,
+
+ /// Cfa - humid subtropical: muggy summers, cool winters. Tokyo.
+ HumidSubtropical,
+
+ /// Cfb - oceanic: narrow temperature range, grey and wet. London.
+ Oceanic,
+
+ /// Dfb - warm-summer continental. Warsaw, and most of central Europe.
+ CentralEuropean,
+
+ /// Dfc - subarctic continental: brutal winters, short warm summers. Yakutsk.
+ Siberian,
+
+ /// ET - tundra: nothing ever really warms up. Murmansk.
+ Tundra,
+
+ /// H - highland: thin air, huge diurnal swing, mild annual range. La Paz.
+ Highland,
+}
+
+/// What the sky is doing right now, as a single label the UI can show.
+public enum WeatherCondition
+{
+ Clear = 0,
+ FewClouds,
+ Cloudy,
+ Overcast,
+ Fog,
+ Drizzle,
+ Rain,
+ HeavyRain,
+ Thunderstorm,
+ Sleet,
+ Snow,
+ HeavySnow,
+ Blizzard,
+ Sandstorm,
+}
diff --git a/src/TheLivingWorld.Core/Simulation/ClimatePreset.cs b/src/TheLivingWorld.Core/Simulation/ClimatePreset.cs
new file mode 100644
index 0000000..0ce4a7e
--- /dev/null
+++ b/src/TheLivingWorld.Core/Simulation/ClimatePreset.cs
@@ -0,0 +1,357 @@
+namespace TheLivingWorld.Core.Simulation;
+
+///
+/// The tunable profile behind one . Everything the weather model needs to turn a
+/// game calendar instant into a plausible sky, and nothing else.
+///
+public sealed record ClimatePreset
+{
+ public required ClimateKind Kind { get; init; }
+
+ /// Köppen code, shown in the UI so the choice is recognisable to anyone who knows the system.
+ public required string KoppenCode { get; init; }
+
+ public required string Label { get; init; }
+
+ /// A real place that feels like this, to make the list pickable without reading numbers.
+ public required string Example { get; init; }
+
+ /// Mean annual temperature at sea level, °C.
+ public required float MeanTemperatureC { get; init; }
+
+ /// Full winter-to-summer swing of the monthly mean, °C.
+ public required float AnnualRangeC { get; init; }
+
+ /// Full night-to-afternoon swing under a clear sky, °C. Clouds damp this.
+ public required float DiurnalRangeC { get; init; }
+
+ /// Baseline relative humidity, 0..1, before the pressure field pushes it around.
+ public required float Humidity { get; init; }
+
+ /// Baseline wind speed, m/s, before the pressure gradient adds to it.
+ public required float WindSpeedMs { get; init; }
+
+ /// How readily cloud turns into actual precipitation, 0..1.
+ public required float Wetness { get; init; }
+
+ ///
+ /// Where the wet season sits, as a fraction of the year offset from midsummer. 0 = rain peaks in summer,
+ /// 0.5 = rain peaks in winter. Ignored when is zero.
+ ///
+ public required float WetSeasonPhase { get; init; }
+
+ /// How much the wet season matters, 0 = rain is spread evenly, 1 = one soaking season.
+ public required float Seasonality { get; init; }
+
+ /// Concurrent pressure systems the model keeps alive. More systems = faster changing weather.
+ public required int Storminess { get; init; }
+
+ /// Convective climates spawn thunderstorms; stable ones just rain.
+ public required float Convectivity { get; init; }
+}
+
+/// The twelve presets, plus the rule that guesses one from a latitude.
+public static class ClimateCatalog
+{
+ public static readonly ClimatePreset Equatorial = new()
+ {
+ Kind = ClimateKind.Equatorial,
+ KoppenCode = "Af",
+ Label = "Equatorial",
+ Example = "Singapore",
+ MeanTemperatureC = 27f,
+ AnnualRangeC = 2f,
+ DiurnalRangeC = 9f,
+ Humidity = 0.85f,
+ WindSpeedMs = 2.5f,
+ Wetness = 0.85f,
+ WetSeasonPhase = 0f,
+ Seasonality = 0.05f,
+ Storminess = 5,
+ Convectivity = 0.9f,
+ };
+
+ public static readonly ClimatePreset TropicalMonsoon = new()
+ {
+ Kind = ClimateKind.TropicalMonsoon,
+ KoppenCode = "Am",
+ Label = "Tropical monsoon",
+ Example = "Mumbai",
+ MeanTemperatureC = 27f,
+ AnnualRangeC = 5f,
+ DiurnalRangeC = 8f,
+ Humidity = 0.78f,
+ WindSpeedMs = 4.5f,
+ Wetness = 0.9f,
+ WetSeasonPhase = 0.08f,
+ Seasonality = 0.85f,
+ Storminess = 5,
+ Convectivity = 0.85f,
+ };
+
+ public static readonly ClimatePreset Savanna = new()
+ {
+ Kind = ClimateKind.Savanna,
+ KoppenCode = "Aw",
+ Label = "Savanna",
+ Example = "Nairobi",
+ MeanTemperatureC = 25f,
+ AnnualRangeC = 6f,
+ DiurnalRangeC = 13f,
+ Humidity = 0.58f,
+ WindSpeedMs = 3.5f,
+ Wetness = 0.5f,
+ WetSeasonPhase = 0.05f,
+ Seasonality = 0.7f,
+ Storminess = 4,
+ Convectivity = 0.8f,
+ };
+
+ public static readonly ClimatePreset HotDesert = new()
+ {
+ Kind = ClimateKind.HotDesert,
+ KoppenCode = "BWh",
+ Label = "Hot desert",
+ Example = "Cairo",
+ MeanTemperatureC = 24f,
+ AnnualRangeC = 18f,
+ DiurnalRangeC = 18f,
+ Humidity = 0.22f,
+ WindSpeedMs = 4f,
+ Wetness = 0.06f,
+ WetSeasonPhase = 0.4f,
+ Seasonality = 0.3f,
+ Storminess = 2,
+ Convectivity = 0.4f,
+ };
+
+ public static readonly ClimatePreset ColdSteppe = new()
+ {
+ Kind = ClimateKind.ColdSteppe,
+ KoppenCode = "BSk",
+ Label = "Cold steppe",
+ Example = "Astana",
+ MeanTemperatureC = 5f,
+ AnnualRangeC = 34f,
+ DiurnalRangeC = 14f,
+ Humidity = 0.45f,
+ WindSpeedMs = 6f,
+ Wetness = 0.3f,
+ WetSeasonPhase = 0.12f,
+ Seasonality = 0.45f,
+ Storminess = 4,
+ Convectivity = 0.5f,
+ };
+
+ public static readonly ClimatePreset Mediterranean = new()
+ {
+ Kind = ClimateKind.Mediterranean,
+ KoppenCode = "Csa",
+ Label = "Mediterranean",
+ Example = "Barcelona",
+ MeanTemperatureC = 17f,
+ AnnualRangeC = 18f,
+ DiurnalRangeC = 11f,
+ Humidity = 0.6f,
+ WindSpeedMs = 4f,
+ Wetness = 0.45f,
+ WetSeasonPhase = 0.5f,
+ Seasonality = 0.75f,
+ Storminess = 3,
+ Convectivity = 0.45f,
+ };
+
+ public static readonly ClimatePreset HumidSubtropical = new()
+ {
+ Kind = ClimateKind.HumidSubtropical,
+ KoppenCode = "Cfa",
+ Label = "Humid subtropical",
+ Example = "Tokyo",
+ MeanTemperatureC = 16f,
+ AnnualRangeC = 22f,
+ DiurnalRangeC = 9f,
+ Humidity = 0.72f,
+ WindSpeedMs = 4f,
+ Wetness = 0.7f,
+ WetSeasonPhase = 0.1f,
+ Seasonality = 0.35f,
+ Storminess = 5,
+ Convectivity = 0.75f,
+ };
+
+ public static readonly ClimatePreset Oceanic = new()
+ {
+ Kind = ClimateKind.Oceanic,
+ KoppenCode = "Cfb",
+ Label = "Oceanic",
+ Example = "London",
+ MeanTemperatureC = 11f,
+ AnnualRangeC = 14f,
+ DiurnalRangeC = 7f,
+ Humidity = 0.8f,
+ WindSpeedMs = 6f,
+ Wetness = 0.65f,
+ WetSeasonPhase = 0.4f,
+ Seasonality = 0.2f,
+ Storminess = 6,
+ Convectivity = 0.25f,
+ };
+
+ public static readonly ClimatePreset CentralEuropean = new()
+ {
+ Kind = ClimateKind.CentralEuropean,
+ KoppenCode = "Dfb",
+ Label = "Central European",
+ Example = "Warsaw",
+ MeanTemperatureC = 8f,
+ AnnualRangeC = 24f,
+ DiurnalRangeC = 10f,
+ Humidity = 0.72f,
+ WindSpeedMs = 4.5f,
+ Wetness = 0.5f,
+ WetSeasonPhase = 0.08f,
+ Seasonality = 0.2f,
+ Storminess = 5,
+ Convectivity = 0.55f,
+ };
+
+ public static readonly ClimatePreset Siberian = new()
+ {
+ Kind = ClimateKind.Siberian,
+ KoppenCode = "Dfc",
+ Label = "Siberian",
+ Example = "Yakutsk",
+ MeanTemperatureC = -8f,
+ AnnualRangeC = 42f,
+ DiurnalRangeC = 12f,
+ Humidity = 0.7f,
+ WindSpeedMs = 3.5f,
+ Wetness = 0.35f,
+ WetSeasonPhase = 0.06f,
+ Seasonality = 0.4f,
+ Storminess = 3,
+ Convectivity = 0.35f,
+ };
+
+ public static readonly ClimatePreset Tundra = new()
+ {
+ Kind = ClimateKind.Tundra,
+ KoppenCode = "ET",
+ Label = "Tundra",
+ Example = "Murmansk",
+ MeanTemperatureC = -7f,
+ AnnualRangeC = 24f,
+ DiurnalRangeC = 6f,
+ Humidity = 0.82f,
+ WindSpeedMs = 7.5f,
+ Wetness = 0.4f,
+ WetSeasonPhase = 0.15f,
+ Seasonality = 0.25f,
+ Storminess = 5,
+ Convectivity = 0.15f,
+ };
+
+ public static readonly ClimatePreset Highland = new()
+ {
+ Kind = ClimateKind.Highland,
+ KoppenCode = "H",
+ Label = "Highland",
+ Example = "La Paz",
+ MeanTemperatureC = 6f,
+ AnnualRangeC = 12f,
+ DiurnalRangeC = 16f,
+ Humidity = 0.55f,
+ WindSpeedMs = 6.5f,
+ Wetness = 0.45f,
+ WetSeasonPhase = 0.05f,
+ Seasonality = 0.6f,
+ Storminess = 4,
+ Convectivity = 0.7f,
+ };
+
+ /// Every preset, in the order the picker should show them: hot to cold.
+ public static readonly IReadOnlyList All =
+ [
+ Equatorial,
+ TropicalMonsoon,
+ Savanna,
+ HotDesert,
+ ColdSteppe,
+ Mediterranean,
+ HumidSubtropical,
+ Oceanic,
+ CentralEuropean,
+ Siberian,
+ Tundra,
+ Highland,
+ ];
+
+ private static readonly Dictionary ByKind =
+ All.ToDictionary(static preset => preset.Kind);
+
+ public static ClimatePreset Get(ClimateKind kind) =>
+ ByKind.TryGetValue(kind, out var preset)
+ ? preset
+ : throw new ArgumentOutOfRangeException(nameof(kind), kind, "Unknown climate.");
+
+ ///
+ /// The default climate for a place, from latitude alone. Latitude cannot tell a steppe from a monsoon
+ /// coast - continentality and altitude decide that - so the drier and higher presets
+ /// (, ,
+ /// ) are never guessed and stay a deliberate choice.
+ ///
+ /// The bands are a best effort, not a classification: London and Warsaw are less than a degree apart and
+ /// belong to different climates entirely. This picks a sensible default for the create form, which the
+ /// player is expected to override when they know better.
+ ///
+ ///
+ public static ClimateKind FromLatitude(double latitude)
+ {
+ var band = Math.Abs(latitude);
+
+ foreach (var (limit, kind) in Bands)
+ {
+ if (band < limit) return kind;
+ }
+
+ return Bands[^1].Kind;
+ }
+
+ ///
+ /// Absolute latitude below which returns this preset, or null when the rule
+ /// never picks it. Served to the client so the create form can preview the guess without keeping its own
+ /// copy of the table.
+ ///
+ public static double? BandLimit(ClimateKind kind)
+ {
+ foreach (var (limit, banded) in Bands)
+ {
+ if (banded == kind) return limit;
+ }
+
+ return null;
+ }
+
+ ///
+ /// The presets can actually produce. The rest exist only because a player
+ /// asked for them, and the UI says so rather than leaving them looking broken.
+ ///
+ public static bool IsInferable(ClimateKind kind) => BandLimit(kind) is not null;
+
+ ///
+ /// Equator to pole, each entry claiming everything below its limit that an earlier entry did not.
+ /// Ordered, and the last entry doubles as the fallback at the pole.
+ ///
+ private static readonly (double Limit, ClimateKind Kind)[] Bands =
+ [
+ (10, ClimateKind.Equatorial),
+ (20, ClimateKind.Savanna),
+ (33, ClimateKind.HotDesert),
+ (41, ClimateKind.Mediterranean),
+ (48, ClimateKind.HumidSubtropical),
+ (54, ClimateKind.Oceanic),
+ (62, ClimateKind.CentralEuropean),
+ (70, ClimateKind.Siberian),
+ (90.1, ClimateKind.Tundra),
+ ];
+}
diff --git a/src/TheLivingWorld.Core/Simulation/DeterministicRandom.cs b/src/TheLivingWorld.Core/Simulation/DeterministicRandom.cs
new file mode 100644
index 0000000..71cd79c
--- /dev/null
+++ b/src/TheLivingWorld.Core/Simulation/DeterministicRandom.cs
@@ -0,0 +1,40 @@
+namespace TheLivingWorld.Core.Simulation;
+
+///
+/// splitmix64. Chosen because its entire state is one , which means a world's weather can
+/// be persisted and resumed exactly rather than re-rolled on every restart.
+///
+public struct DeterministicRandom(ulong state)
+{
+ public ulong State = state;
+
+ public ulong NextUInt64()
+ {
+ State += 0x9E3779B97F4A7C15UL;
+ var z = State;
+ z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
+ z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
+ return z ^ (z >> 31);
+ }
+
+ /// Uniform in [0, 1).
+ public float NextSingle() => (NextUInt64() >> 40) * (1f / 16777216f);
+
+ public float Range(float min, float max) => min + (NextSingle() * (max - min));
+
+ public bool Chance(float probability) => NextSingle() < probability;
+
+ /// A stable seed for a world id, so the same world always gets the same weather sequence.
+ public static ulong SeedFrom(string text)
+ {
+ // FNV-1a: we only need "different strings land far apart", not cryptographic quality.
+ var hash = 14695981039346656037UL;
+ foreach (var character in text)
+ {
+ hash ^= character;
+ hash *= 1099511628211UL;
+ }
+
+ return hash;
+ }
+}
diff --git a/src/TheLivingWorld.Core/Simulation/SimulationComponents.cs b/src/TheLivingWorld.Core/Simulation/SimulationComponents.cs
new file mode 100644
index 0000000..8e064c4
--- /dev/null
+++ b/src/TheLivingWorld.Core/Simulation/SimulationComponents.cs
@@ -0,0 +1,33 @@
+using Arch.Core;
+using TheLivingWorld.Core.Ecs;
+
+namespace TheLivingWorld.Core.Simulation;
+
+///
+/// Registers every component a live world uses, once, before any of them can be touched concurrently.
+///
+/// Arch hands out component type ids on first use and does not guard that with a lock, so two threads
+/// first-touching two different component types at the same moment can be handed the same id - after which
+/// a query for one component reads another's memory and the clock starts ticking on garbage. Worlds are
+/// created from request threads and from the simulation host, so that race is reachable in production, not
+/// just under a parallel test runner.
+///
+///
+/// A static constructor is the fix: the CLR guarantees it runs exactly once and blocks every other thread
+/// until it finishes.
+///
+///
+public static class SimulationComponents
+{
+ static SimulationComponents()
+ {
+ var probe = World.Create();
+ probe.Create(new GameClock(), new PressureSystem(), new WeatherState());
+ World.Destroy(probe);
+ }
+
+ /// Touches this type, which forces the static constructor above to have run.
+ public static void EnsureRegistered()
+ {
+ }
+}
diff --git a/src/TheLivingWorld.Core/Simulation/WeatherModel.cs b/src/TheLivingWorld.Core/Simulation/WeatherModel.cs
new file mode 100644
index 0000000..b083486
--- /dev/null
+++ b/src/TheLivingWorld.Core/Simulation/WeatherModel.cs
@@ -0,0 +1,344 @@
+using TheLivingWorld.Core.Ecs;
+
+namespace TheLivingWorld.Core.Simulation;
+
+/// Everything the weather looks like at one point of one world at one instant.
+public readonly record struct WeatherSample
+{
+ public required float TemperatureC { get; init; }
+
+ /// Wind chill below 10 °C, heat index above 26 °C, plain temperature in between.
+ public required float FeelsLikeC { get; init; }
+
+ public required float PressureHpa { get; init; }
+
+ /// Relative humidity, 0..1.
+ public required float Humidity { get; init; }
+
+ /// Fraction of the sky covered, 0..1.
+ public required float CloudCover { get; init; }
+
+ public required float PrecipitationMmH { get; init; }
+
+ public required float WindSpeedMs { get; init; }
+
+ /// Meteorological convention: the compass bearing the wind blows from, 0..360.
+ public required float WindDirectionDeg { get; init; }
+
+ public required WeatherCondition Condition { get; init; }
+}
+
+///
+/// Pure weather maths. A deterministic climate baseline (season and time of day) with the simulated pressure
+/// field laid over it - the hybrid half of the model. Nothing here touches ECS or time; feed it a sampled
+/// pressure field and it tells you what the sky is doing.
+///
+public static class WeatherModel
+{
+ public const float SeaLevelPressureHpa = 1013.25f;
+
+ /// Northern hemisphere peak warmth, ~19 July: the calendar lags the solstice by about a month.
+ private const float WarmestDayOfYear = 200f;
+
+ /// Afternoon peak, lagging solar noon for the same reason.
+ private const float WarmestHour = 15f;
+
+ private const float DaysPerYear = 365.2425f;
+
+ /// -1 at midwinter, +1 at midsummer, flipped below the equator.
+ public static float SeasonPhase(DateTime gameTime, double latitude)
+ {
+ var dayOfYear = gameTime.DayOfYear + (gameTime.TimeOfDay.TotalHours / 24.0);
+ var phase = MathF.Cos((float)((dayOfYear - WarmestDayOfYear) / DaysPerYear * 2.0 * Math.PI));
+ return latitude < 0 ? -phase : phase;
+ }
+
+ /// -1 at the coldest hour before dawn, +1 mid-afternoon.
+ public static float DiurnalPhase(DateTime gameTime)
+ {
+ var hour = (float)gameTime.TimeOfDay.TotalHours;
+ return MathF.Cos((hour - WarmestHour) / 24f * 2f * MathF.PI);
+ }
+
+ ///
+ /// How wet this part of the year is for a climate: 1 is the annual average, higher in the wet season.
+ /// Climates with no sit flat at 1 all year.
+ ///
+ public static float WetSeasonFactor(ClimatePreset climate, DateTime gameTime, double latitude)
+ {
+ var dayOfYear = (float)(gameTime.DayOfYear + gameTime.TimeOfDay.TotalHours / 24.0);
+ var fromMidsummer = (dayOfYear - WarmestDayOfYear) / DaysPerYear;
+ if (latitude < 0) fromMidsummer += 0.5f;
+
+ var swing = MathF.Cos((fromMidsummer - climate.WetSeasonPhase) * 2f * MathF.PI);
+ return 1f + (climate.Seasonality * swing);
+ }
+
+ ///
+ /// Turns a sampled pressure field into a full weather reading.
+ ///
+ /// Pressure departure from the standard atmosphere at this point.
+ /// Pressure change per unit of normalised world space, eastwards.
+ /// Same, northwards.
+ public static WeatherSample Sample(
+ ClimatePreset climate,
+ double latitude,
+ DateTime gameTime,
+ float anomalyHpa,
+ float gradientX,
+ float gradientY)
+ {
+ var season = SeasonPhase(gameTime, latitude);
+ var diurnal = DiurnalPhase(gameTime);
+ var wetFactor = WetSeasonFactor(climate, gameTime, latitude);
+
+ var cloudCover = CloudCover(climate, anomalyHpa, wetFactor);
+ var (windSpeed, windVectorX, windVectorY) = Wind(climate, latitude, gradientX, gradientY);
+
+ // Wind off the equator is warm, wind off the pole is cold; which is which flips by hemisphere.
+ var poleward = latitude < 0 ? -windVectorY : windVectorY;
+ var advection = 0.35f * poleward;
+
+ var temperature =
+ climate.MeanTemperatureC
+ + (climate.AnnualRangeC / 2f * season)
+ // An overcast sky traps the night's heat and blocks the day's, so it flattens the daily swing.
+ + (climate.DiurnalRangeC / 2f * diurnal * (1f - (0.65f * cloudCover)))
+ + advection;
+
+ var humidity = Humidity(climate, cloudCover, diurnal);
+ var precipitation = Precipitation(climate, cloudCover, wetFactor);
+
+ return new WeatherSample
+ {
+ TemperatureC = temperature,
+ FeelsLikeC = FeelsLike(temperature, windSpeed, humidity),
+ PressureHpa = SeaLevelPressureHpa + anomalyHpa,
+ Humidity = humidity,
+ CloudCover = cloudCover,
+ PrecipitationMmH = precipitation,
+ WindSpeedMs = windSpeed,
+ WindDirectionDeg = WindDirection(windVectorX, windVectorY),
+ Condition = Classify(climate, temperature, humidity, cloudCover, precipitation, windSpeed, diurnal),
+ };
+ }
+
+ private static float CloudCover(ClimatePreset climate, float anomalyHpa, float wetFactor)
+ {
+ // Damp climates start out greyer; falling pressure means convergence, and convergence means cloud.
+ var baseline = climate.Humidity * 0.5f;
+ var fromPressure = Math.Clamp(-anomalyHpa / 20f, 0f, 1f);
+ return Math.Clamp((baseline + fromPressure) * wetFactor, 0f, 1f);
+ }
+
+ private static float Precipitation(ClimatePreset climate, float cloudCover, float wetFactor)
+ {
+ // Cloud has to build past a threshold before anything actually falls out of it.
+ const float Threshold = 0.55f;
+ if (cloudCover <= Threshold) return 0f;
+
+ var potential = (cloudCover - Threshold) / (1f - Threshold);
+ return MathF.Pow(potential, 1.6f) * climate.Wetness * Math.Max(wetFactor, 0f) * 12f;
+ }
+
+ private static float Humidity(ClimatePreset climate, float cloudCover, float diurnal)
+ {
+ // Afternoon air holds the same water at a lower relative humidity, so the daily curve runs backwards.
+ var value = climate.Humidity * (0.85f + (0.4f * cloudCover)) - (0.12f * diurnal);
+ return Math.Clamp(value, 0.05f, 1f);
+ }
+
+ private static (float Speed, float VectorX, float VectorY) Wind(
+ ClimatePreset climate,
+ double latitude,
+ float gradientX,
+ float gradientY)
+ {
+ // Geostrophic flow runs along the isobars, not down them: rotate the gradient a quarter turn, one way
+ // in the north and the other in the south. Friction then bends it slightly towards the low.
+ var sense = latitude < 0 ? -1f : 1f;
+ var alongX = -gradientY * sense;
+ var alongY = gradientX * sense;
+
+ const float Friction = 0.3f;
+ var vectorX = alongX - (Friction * gradientX);
+ var vectorY = alongY - (Friction * gradientY);
+
+ var gradientSpeed = MathF.Sqrt((vectorX * vectorX) + (vectorY * vectorY)) * 0.5f;
+ var speed = climate.WindSpeedMs + gradientSpeed;
+
+ // Even a flat pressure field has a prevailing breeze, so give the vector a floor to point along.
+ if (vectorX == 0f && vectorY == 0f) return (speed, 0f, -1f);
+
+ var length = MathF.Sqrt((vectorX * vectorX) + (vectorY * vectorY));
+ return (speed, vectorX / length * speed, vectorY / length * speed);
+ }
+
+ /// Compass bearing the wind arrives from, which is the reverse of where it is heading.
+ private static float WindDirection(float vectorX, float vectorY)
+ {
+ var heading = MathF.Atan2(vectorX, vectorY) * 180f / MathF.PI;
+ var from = heading + 180f;
+ return ((from % 360f) + 360f) % 360f;
+ }
+
+ private static float FeelsLike(float temperature, float windSpeedMs, float humidity)
+ {
+ if (temperature <= 10f && windSpeedMs > 1.3f)
+ {
+ // Environment Canada wind chill, converted from km/h to m/s.
+ var windKmh = MathF.Pow(windSpeedMs * 3.6f, 0.16f);
+ return 13.12f + (0.6215f * temperature) - (11.37f * windKmh) + (0.3965f * temperature * windKmh);
+ }
+
+ if (temperature >= 26f)
+ {
+ // Humidity only starts to matter when the air is already hot; this is the simple humidex shape.
+ var vapour = humidity * 6.112f * MathF.Exp(17.67f * temperature / (temperature + 243.5f));
+ return temperature + (0.5555f * (vapour - 10f));
+ }
+
+ return temperature;
+ }
+
+ private static WeatherCondition Classify(
+ ClimatePreset climate,
+ float temperature,
+ float humidity,
+ float cloudCover,
+ float precipitationMmH,
+ float windSpeedMs,
+ float diurnal)
+ {
+ if (precipitationMmH > 0.05f)
+ {
+ if (temperature < 0.5f)
+ {
+ if (windSpeedMs > 12f && precipitationMmH > 1.5f) return WeatherCondition.Blizzard;
+ return precipitationMmH > 3f ? WeatherCondition.HeavySnow : WeatherCondition.Snow;
+ }
+
+ if (temperature < 2.5f) return WeatherCondition.Sleet;
+
+ // Thunderstorms need heat, instability and a heavy shower - they are an afternoon phenomenon.
+ if (precipitationMmH > 4.5f
+ && temperature > 15f
+ && diurnal > 0f
+ && climate.Convectivity > 0.5f)
+ {
+ return WeatherCondition.Thunderstorm;
+ }
+
+ if (precipitationMmH > 4f) return WeatherCondition.HeavyRain;
+ return precipitationMmH > 1f ? WeatherCondition.Rain : WeatherCondition.Drizzle;
+ }
+
+ // Nothing falling: a dry climate with a strong wind is lifting dust instead.
+ if (climate.Wetness < 0.15f && windSpeedMs > 11f) return WeatherCondition.Sandstorm;
+
+ // Fog wants saturated, still air, and it burns off once the sun is up.
+ if (humidity > 0.92f && windSpeedMs < 3f && diurnal < 0f) return WeatherCondition.Fog;
+
+ if (cloudCover > 0.85f) return WeatherCondition.Overcast;
+ if (cloudCover > 0.55f) return WeatherCondition.Cloudy;
+ return cloudCover > 0.25f ? WeatherCondition.FewClouds : WeatherCondition.Clear;
+ }
+
+ /// Above this the sky rains rather than snows, and lying snow starts to go.
+ private const float FreezingC = 0.5f;
+
+ /// Fresh snow is mostly air: a millimetre of water lands as roughly a centimetre of snow.
+ private const float SnowWaterRatio = 10f;
+
+ /// Millimetres of snow lost per hour per degree above freezing.
+ private const float MeltRateMmPerDegreeHour = 1.6f;
+
+ public const float MaxSnowDepthMm = 600f;
+
+ /// Lying snow deep enough to have covered everything, used to normalise the renderer's wash.
+ public const float FullCoverDepthMm = 120f;
+
+ ///
+ /// Integrates lying snow over one step. Snow falling below freezing piles up; anything above it melts,
+ /// faster the warmer it gets. This is the one piece of weather with memory - everything else is a
+ /// function of the current instant, but you cannot tell how deep the snow is without knowing the past.
+ ///
+ public static float UpdateSnowDepth(
+ float current,
+ float temperatureC,
+ float precipitationMmH,
+ float elapsedHours)
+ {
+ if (elapsedHours <= 0f) return Math.Clamp(current, 0f, MaxSnowDepthMm);
+
+ if (temperatureC < FreezingC)
+ {
+ var fallen = precipitationMmH * SnowWaterRatio * elapsedHours;
+ return Math.Clamp(current + fallen, 0f, MaxSnowDepthMm);
+ }
+
+ var melted = (temperatureC - FreezingC) * MeltRateMmPerDegreeHour * elapsedHours;
+ return Math.Clamp(current - melted, 0f, MaxSnowDepthMm);
+ }
+
+ ///
+ /// 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:
+ /// a Siberian world opened in January should already be under snow, not waiting for the first fall.
+ ///
+ public static float SeasonalSnowDepth(ClimatePreset climate, double latitude, DateTime gameTime)
+ {
+ var baseline = climate.MeanTemperatureC + (climate.AnnualRangeC / 2f * SeasonPhase(gameTime, latitude));
+ if (baseline >= FreezingC) return 0f;
+
+ // Deeper the colder it is, and deeper again in a wet climate - but a dry one still ends up buried,
+ // because what little falls in Siberia never gets a thaw to take it away.
+ var coldness = Math.Clamp((FreezingC - baseline) / 20f, 0f, 1f);
+ return coldness * (0.4f + (0.6f * climate.Wetness)) * FullCoverDepthMm * 3f;
+ }
+
+ ///
+ /// Sums every pressure system's Gaussian bump at one point, returning the anomaly and its gradient.
+ /// Both come out of the same pass because the gradient is just the bump's analytic derivative.
+ ///
+ public static (float Anomaly, float GradientX, float GradientY) SampleField(
+ ReadOnlySpan systems,
+ float x,
+ float y)
+ {
+ var anomaly = 0f;
+ var gradientX = 0f;
+ var gradientY = 0f;
+
+ foreach (var system in systems)
+ {
+ var dx = x - system.X;
+ var dy = y - system.Y;
+ var radius = MathF.Max(system.Radius, 0.01f);
+ var falloff = MathF.Exp(-((dx * dx) + (dy * dy)) / (2f * radius * radius));
+
+ var strength = system.IntensityHpa * Envelope(system) * falloff;
+ anomaly += strength;
+ gradientX += strength * -dx / (radius * radius);
+ gradientY += strength * -dy / (radius * radius);
+ }
+
+ return (anomaly, gradientX, gradientY);
+ }
+
+ ///
+ /// Fades a system in over its first fifth of life and out over its last, so systems drift in and decay
+ /// instead of popping into existence at full strength.
+ ///
+ public static float Envelope(in PressureSystem system)
+ {
+ if (system.LifetimeHours <= 0f) return 0f;
+
+ var progress = Math.Clamp(system.AgeHours / system.LifetimeHours, 0f, 1f);
+ const float Ramp = 0.2f;
+
+ if (progress < Ramp) return progress / Ramp;
+ if (progress > 1f - Ramp) return (1f - progress) / Ramp;
+ return 1f;
+ }
+}
diff --git a/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs b/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs
new file mode 100644
index 0000000..716a5ac
--- /dev/null
+++ b/src/TheLivingWorld.Core/Simulation/WeatherSystem.cs
@@ -0,0 +1,229 @@
+using Arch.Core;
+using TheLivingWorld.Core.Ecs;
+
+namespace TheLivingWorld.Core.Simulation;
+
+///
+/// Moves the pressure systems that make up a world's weather. Systems drift across the map, fade in and out,
+/// and are recycled in place when they expire - the pool never grows or shrinks, so a tick never causes a
+/// structural change in the ECS world.
+///
+public static class WeatherSystem
+{
+ /// Upper bound on , and the size of every stack buffer here.
+ public const int MaxSystems = 8;
+
+ private static readonly QueryDescription Systems = new QueryDescription().WithAll();
+
+ ///
+ /// How far a system drifts per game hour, in normalised world space. Deliberately independent of world
+ /// size: a real front crosses ten kilometres in minutes, which at five game minutes per real second would
+ /// be a flicker. This pins a crossing at roughly half a game day whatever the map measures.
+ ///
+ private const float MinDriftPerHour = 0.035f;
+
+ private const float MaxDriftPerHour = 0.11f;
+
+ /// Beyond this distance from the map a system is spent and gets recycled.
+ private const float OffMapLimit = 1.9f;
+
+ /// Creates the weather state entity and the pool of pressure systems for a fresh world.
+ public static void Seed(
+ World ecs,
+ ClimatePreset climate,
+ double latitude,
+ ulong seed,
+ DateTime gameTime)
+ {
+ SimulationComponents.EnsureRegistered();
+
+ var random = new DeterministicRandom(seed);
+ var count = Math.Clamp(climate.Storminess, 1, MaxSystems);
+
+ for (var i = 0; i < count; i++)
+ ecs.Create(SpawnAlreadyRunning(climate, latitude, ref random));
+
+ ecs.Create(new WeatherState(random.State, WeatherModel.SeasonalSnowDepth(climate, latitude, gameTime)));
+ }
+
+ ///
+ /// Restores a persisted sky. Falls back to when the stored pool is empty, so a world
+ /// written before weather existed still gets one.
+ ///
+ public static void Restore(
+ World ecs,
+ ClimatePreset climate,
+ double latitude,
+ ulong fallbackSeed,
+ DateTime gameTime,
+ ulong rngState,
+ float snowDepthMm,
+ ReadOnlySpan systems)
+ {
+ SimulationComponents.EnsureRegistered();
+
+ if (systems.IsEmpty)
+ {
+ Seed(ecs, climate, latitude, fallbackSeed, gameTime);
+ return;
+ }
+
+ foreach (var system in systems[..Math.Min(systems.Length, MaxSystems)])
+ ecs.Create(system);
+
+ ecs.Create(new WeatherState(rngState, Math.Clamp(snowDepthMm, 0f, WeatherModel.MaxSnowDepthMm)));
+ }
+
+ ///
+ /// Throws the current sky away and rolls a fresh one for this season. Used when a world comes back after
+ /// a gap too long to simulate: the pressure systems that were drifting are long gone, and replaying them
+ /// would cost more than the result is worth.
+ ///
+ public static void Reseed(World ecs, ClimatePreset climate, double latitude, DateTime gameTime)
+ {
+ Span entities = stackalloc Entity[MaxSystems];
+ var count = Gather(ecs, entities);
+
+ ref var state = ref StateOf(ecs);
+ var random = new DeterministicRandom(state.RngState);
+
+ for (var i = 0; i < count; i++)
+ ecs.Get(entities[i]) = SpawnAlreadyRunning(climate, latitude, ref random);
+
+ state.RngState = random.State;
+ // Whatever was lying on the ground melted or fell while we were away; take the season's word for it.
+ state.SnowDepthMm = WeatherModel.SeasonalSnowDepth(climate, latitude, gameTime);
+ }
+
+ /// Advances every pressure system, recycling the ones that have blown through or died out.
+ public static void Execute(World ecs, ClimatePreset climate, double latitude, float elapsedGameHours)
+ {
+ if (elapsedGameHours <= 0f) return;
+
+ Span entities = stackalloc Entity[MaxSystems];
+ var count = Gather(ecs, entities);
+ if (count == 0) return;
+
+ ref var state = ref StateOf(ecs);
+ var random = new DeterministicRandom(state.RngState);
+
+ for (var i = 0; i < count; i++)
+ {
+ ref var system = ref ecs.Get(entities[i]);
+
+ system.AgeHours += elapsedGameHours;
+ system.X += system.VelocityX * elapsedGameHours;
+ system.Y += system.VelocityY * elapsedGameHours;
+
+ var spent = system.AgeHours >= system.LifetimeHours
+ || MathF.Abs(system.X - 0.5f) > OffMapLimit
+ || MathF.Abs(system.Y - 0.5f) > OffMapLimit;
+
+ if (spent) system = Spawn(climate, latitude, ref random);
+ }
+
+ state.RngState = random.State;
+ }
+
+ /// The PRNG state, so it can be persisted alongside the systems it will spawn next.
+ public static ulong RngState(World ecs) => StateOf(ecs).RngState;
+
+ public static float SnowDepthMm(World ecs) => StateOf(ecs).SnowDepthMm;
+
+ ///
+ /// Piles up or melts the lying snow for one step, given what the sky was doing over the map.
+ ///
+ public static void AccumulateSnow(
+ World ecs,
+ float temperatureC,
+ float precipitationMmH,
+ float elapsedGameHours)
+ {
+ ref var state = ref StateOf(ecs);
+ state.SnowDepthMm = WeatherModel.UpdateSnowDepth(
+ state.SnowDepthMm, temperatureC, precipitationMmH, elapsedGameHours);
+ }
+
+ ///
+ /// Copies the live pressure systems out for sampling. Returns how many were written. The order is
+ /// whatever the query hands back, not the order they were created in - the field is a sum over all of
+ /// them, so nothing downstream may depend on it.
+ ///
+ public static int CopySystems(World ecs, Span destination)
+ {
+ Span entities = stackalloc Entity[MaxSystems];
+ var count = Math.Min(Gather(ecs, entities), destination.Length);
+
+ for (var i = 0; i < count; i++)
+ destination[i] = ecs.Get(entities[i]);
+
+ return count;
+ }
+
+ ///
+ /// A system caught mid-life somewhere over the map, rather than one just entering from the edge. Used
+ /// wherever a world needs a sky that already looks lived-in instead of one that pulses into existence.
+ ///
+ private static PressureSystem SpawnAlreadyRunning(
+ ClimatePreset climate,
+ double latitude,
+ ref DeterministicRandom random)
+ {
+ var system = Spawn(climate, latitude, ref random);
+ system.AgeHours = random.Range(0f, system.LifetimeHours);
+ system.X += system.VelocityX * system.AgeHours;
+ system.Y += system.VelocityY * system.AgeHours;
+ return system;
+ }
+
+ private static PressureSystem Spawn(ClimatePreset climate, double latitude, ref DeterministicRandom random)
+ {
+ // Tropics sit under the trade winds and run east to west; everything poleward of them is in the
+ // westerlies and runs the other way.
+ var eastward = Math.Abs(latitude) >= 30;
+ var drift = random.Range(MinDriftPerHour, MaxDriftPerHour);
+
+ var velocityX = eastward ? drift : -drift;
+ var velocityY = drift * random.Range(-0.35f, 0.35f);
+
+ // Enter from the upwind edge with enough margin that the system fades in off-map.
+ var x = eastward ? -0.45f : 1.45f;
+ var y = random.Range(-0.25f, 1.25f);
+
+ // Stormier climates dig deeper lows; calm ones mostly sit under gentle highs.
+ var cyclone = random.Chance(0.35f + (climate.Storminess * 0.05f));
+ var magnitude = random.Range(5f, 8f + (climate.Storminess * 2.6f));
+
+ return new PressureSystem(
+ X: x,
+ Y: y,
+ VelocityX: velocityX,
+ VelocityY: velocityY,
+ IntensityHpa: cyclone ? -magnitude : magnitude * 0.7f,
+ Radius: random.Range(0.3f, 0.95f),
+ AgeHours: 0f,
+ LifetimeHours: random.Range(18f, 72f));
+ }
+
+ ///
+ /// Fills with the pressure-system entities and returns how many there
+ /// are. Arch's GetEntities writes into the span but does not report a count, so the count comes first.
+ ///
+ private static int Gather(World ecs, Span destination)
+ {
+ var count = Math.Min(ecs.CountEntities(in Systems), destination.Length);
+ if (count > 0) ecs.GetEntities(in Systems, destination);
+ return count;
+ }
+
+ private static ref WeatherState StateOf(World ecs)
+ {
+ var description = new QueryDescription().WithAll();
+ if (ecs.CountEntities(in description) == 0)
+ throw new InvalidOperationException("World has no weather state entity.");
+
+ Span holder = stackalloc Entity[1];
+ ecs.GetEntities(in description, holder);
+ return ref ecs.Get(holder[0]);
+ }
+}
diff --git a/src/TheLivingWorld.Web/index.html b/src/TheLivingWorld.Web/index.html
index 9e9f26a..74efe90 100644
--- a/src/TheLivingWorld.Web/index.html
+++ b/src/TheLivingWorld.Web/index.html
@@ -78,6 +78,14 @@
/>
+
+
@@ -97,6 +105,7 @@