From 2a8b7b49b3ef2f7a5523500906eced691c436494 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sun, 16 Aug 2026 23:05:11 +0300 Subject: [PATCH] Implement climate and weather features in world simulation; enhance API with weather retrieval and climate selection options, update UI to support climate selection during world creation, and improve weather display in the game interface. --- AGENTS.md | 8 +- README.md | 40 ++ .../Endpoints/WorldEndpoints.cs | 40 ++ .../Generation/WorldGenerationService.cs | 5 + .../Simulation/WorldSimulation.cs | 237 +++++++++++- .../Simulation/WorldSimulationHost.cs | 10 +- .../Contracts/WorldContracts.cs | 124 ++++++ src/TheLivingWorld.Core/Ecs/Components.cs | 22 ++ .../Simulation/ClimateKind.cs | 63 ++++ .../Simulation/ClimatePreset.cs | 357 ++++++++++++++++++ .../Simulation/DeterministicRandom.cs | 40 ++ .../Simulation/SimulationComponents.cs | 33 ++ .../Simulation/WeatherModel.cs | 344 +++++++++++++++++ .../Simulation/WeatherSystem.cs | 229 +++++++++++ src/TheLivingWorld.Web/index.html | 9 + src/TheLivingWorld.Web/src/api/client.ts | 6 + src/TheLivingWorld.Web/src/api/types.ts | 51 +++ src/TheLivingWorld.Web/src/main.ts | 116 +++++- src/TheLivingWorld.Web/src/map/mapView.ts | 64 +++- src/TheLivingWorld.Web/src/map/sky.test.ts | 169 +++++++++ src/TheLivingWorld.Web/src/map/sky.ts | 189 ++++++++++ .../src/map/weatherField.test.ts | 116 ++++++ .../src/map/weatherField.ts | 107 ++++++ .../src/map/weatherLayer.ts | 193 ++++++++++ src/TheLivingWorld.Web/src/styles.css | 34 +- src/TheLivingWorld.Web/src/ui/climate.test.ts | 72 ++++ src/TheLivingWorld.Web/src/ui/climate.ts | 37 ++ .../src/ui/gameTime.test.ts | 17 + src/TheLivingWorld.Web/src/ui/gameTime.ts | 27 +- src/TheLivingWorld.Web/src/ui/weather.test.ts | 105 ++++++ src/TheLivingWorld.Web/src/ui/weather.ts | 97 +++++ tests/TheLivingWorld.Tests/ClimateTests.cs | 101 +++++ .../TheLivingWorld.Tests/WeatherModelTests.cs | 266 +++++++++++++ .../WeatherSystemTests.cs | 200 ++++++++++ .../WorldGenerationServiceTests.cs | 50 +++ .../WorldSimulationTests.cs | 103 ++++- 36 files changed, 3650 insertions(+), 31 deletions(-) create mode 100644 src/TheLivingWorld.Core/Simulation/ClimateKind.cs create mode 100644 src/TheLivingWorld.Core/Simulation/ClimatePreset.cs create mode 100644 src/TheLivingWorld.Core/Simulation/DeterministicRandom.cs create mode 100644 src/TheLivingWorld.Core/Simulation/SimulationComponents.cs create mode 100644 src/TheLivingWorld.Core/Simulation/WeatherModel.cs create mode 100644 src/TheLivingWorld.Core/Simulation/WeatherSystem.cs create mode 100644 src/TheLivingWorld.Web/src/map/sky.test.ts create mode 100644 src/TheLivingWorld.Web/src/map/sky.ts create mode 100644 src/TheLivingWorld.Web/src/map/weatherField.test.ts create mode 100644 src/TheLivingWorld.Web/src/map/weatherField.ts create mode 100644 src/TheLivingWorld.Web/src/map/weatherLayer.ts create mode 100644 src/TheLivingWorld.Web/src/ui/climate.test.ts create mode 100644 src/TheLivingWorld.Web/src/ui/climate.ts create mode 100644 src/TheLivingWorld.Web/src/ui/weather.test.ts create mode 100644 src/TheLivingWorld.Web/src/ui/weather.ts create mode 100644 tests/TheLivingWorld.Tests/ClimateTests.cs create mode 100644 tests/TheLivingWorld.Tests/WeatherModelTests.cs create mode 100644 tests/TheLivingWorld.Tests/WeatherSystemTests.cs 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 @@