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.

This commit is contained in:
Leonid Pershin
2026-08-16 23:05:11 +03:00
parent 3610ee8051
commit 2a8b7b49b3
36 changed files with 3650 additions and 31 deletions
+7 -1
View File
@@ -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`. 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 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`. Keep wire DTOs in sync: `TheLivingWorld.Core.Contracts` and `src/TheLivingWorld.Web/src/api/types.ts`.
+40
View File
@@ -108,14 +108,42 @@ them without reworking the data model.
| `GET /api/worlds/{id}` | Status of one world | | `GET /api/worlds/{id}` | Status of one world |
| `GET /api/worlds/{id}/map` | Metadata plus the chunk index | | `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}/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` 14). Body: `{ paused?, timeScale? }` | | `PATCH /api/worlds/{id}/clock` | Pause / resume or set speed (`timeScale` 14). Body: `{ paused?, timeScale? }` |
| `DELETE /api/worlds/{id}` | Remove a world and its chunks | | `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 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. 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 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). 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 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 `Graphics.poly()` accepts, so the client never reshapes it. Responses are compressed; chunk files are written
in wire format and streamed straight from disk. 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° - 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 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 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 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 arterials, then land cover, then side streets — and placed greedily, dropping anything that would overlap a
@@ -17,12 +17,27 @@ public static class WorldEndpoints
worlds.MapGet("/{id}", GetWorld); worlds.MapGet("/{id}", GetWorld);
worlds.MapGet("/{id}/map", GetMap); worlds.MapGet("/{id}/map", GetMap);
worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk); worlds.MapGet("/{id}/chunks/{x:int}/{y:int}", GetChunk);
worlds.MapGet("/{id}/weather", GetWeather);
worlds.MapPatch("/{id}/clock", UpdateClock); worlds.MapPatch("/{id}/clock", UpdateClock);
worlds.MapDelete("/{id}", DeleteWorld); worlds.MapDelete("/{id}", DeleteWorld);
app.MapGet("/api/climates", ListClimates).WithTags("worlds");
return app; return app;
} }
/// <summary>The climate picker's source of truth, so the create form cannot drift from the catalogue.</summary>
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<IResult> ListWorlds( private static async Task<IResult> ListWorlds(
WorldStore store, WorldStore store,
WorldGenerationService generation, WorldGenerationService generation,
@@ -113,6 +128,31 @@ public static class WorldEndpoints
return Results.Stream(stream, "application/json"); return Results.Stream(stream, "application/json");
} }
private static async Task<IResult> 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<string, string[]>
{
["id"] = ["Weather is only available when the world is ready."],
});
}
private static async Task<IResult> UpdateClock( private static async Task<IResult> UpdateClock(
string id, string id,
UpdateClockRequest request, UpdateClockRequest request,
@@ -57,6 +57,10 @@ public sealed class WorldGenerationService(
throw new ArgumentException(ex.Message, ex); 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) var name = string.IsNullOrWhiteSpace(request.Name)
? $"{origin.Latitude:F4}, {origin.Longitude:F4}" ? $"{origin.Latitude:F4}, {origin.Longitude:F4}"
: request.Name.Trim(); : request.Name.Trim();
@@ -73,6 +77,7 @@ public sealed class WorldGenerationService(
Stage = "Queued", Stage = "Queued",
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
Clock = WorldSimulation.DefaultClock(startGameTime), Clock = WorldSimulation.DefaultClock(startGameTime),
Climate = climate,
}; };
await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false); await _capacityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
@@ -6,28 +6,46 @@ using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Api.Simulation; namespace TheLivingWorld.Api.Simulation;
/// <summary> /// <summary>
/// Lightweight live runtime for one world: an Arch world holding a single <see cref="GameClock"/> entity. /// Lightweight live runtime for one world: an Arch world holding the <see cref="GameClock"/> entity and the
/// Map geometry stays on disk; only the clock (and later sim state) lives here. /// pressure systems that drive its weather. Map geometry stays on disk; only simulation state lives here.
/// </summary> /// </summary>
public sealed class WorldSimulation : IDisposable public sealed class WorldSimulation : IDisposable
{ {
/// <summary>
/// A gap longer than this is reseeded rather than stepped. The pressure systems that were drifting when
/// the host went down are long gone by then, and replaying days of them would cost more than it is worth.
/// </summary>
private static readonly TimeSpan MaxWeatherCatchUp = TimeSpan.FromHours(24);
private readonly object _gate = new(); private readonly object _gate = new();
private readonly World _ecs; private readonly World _ecs;
private readonly Entity _clockEntity; private readonly Entity _clockEntity;
private readonly ClimatePreset _climate;
private readonly double _latitude;
private DateTimeOffset _lastTickedAt; private DateTimeOffset _lastTickedAt;
private bool _dirty; private bool _dirty;
private bool _disposed; 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; WorldId = worldId;
_ecs = ecs; _ecs = ecs;
_clockEntity = clockEntity; _clockEntity = clockEntity;
_climate = climate;
_latitude = latitude;
_lastTickedAt = lastTickedAt; _lastTickedAt = lastTickedAt;
} }
public string WorldId { get; } public string WorldId { get; }
public ClimateKind Climate => _climate.Kind;
public bool IsDirty public bool IsDirty
{ {
get get
@@ -43,16 +61,20 @@ public sealed class WorldSimulation : IDisposable
public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true) public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true)
{ {
ArgumentNullException.ThrowIfNull(summary); ArgumentNullException.ThrowIfNull(summary);
SimulationComponents.EnsureRegistered();
var clock = summary.Clock ?? DefaultClock(); var clock = summary.Clock ?? DefaultClock();
var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale; var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale;
var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified); var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified);
var climate = ClimateCatalog.Get(summary.Climate ?? ClimateCatalog.FromLatitude(summary.Latitude));
var ecs = World.Create(); var ecs = World.Create();
var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused)); 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) if (catchUp && !clock.Paused)
{ {
@@ -68,6 +90,75 @@ public sealed class WorldSimulation : IDisposable
return simulation; 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<PressureSystem> 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<PressureSystem> 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() public static WorldClockDto DefaultClock(DateTime? startGameTime = null) => new()
{ {
GameTime = GameTime.ResolveStart(startGameTime), GameTime = GameTime.ResolveStart(startGameTime),
@@ -81,19 +172,55 @@ public sealed class WorldSimulation : IDisposable
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
if (realElapsed > TimeSpan.Zero) if (realElapsed > TimeSpan.Zero && AdvanceUnlocked(realElapsed)) _dirty = true;
{
// Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
// allocate for nothing.
var before = _ecs.Get<GameClock>(_clockEntity).Ticks;
ClockSystem.Execute(_ecs, realElapsed);
if (_ecs.Get<GameClock>(_clockEntity).Ticks != before) _dirty = true;
}
_lastTickedAt = DateTimeOffset.UtcNow; _lastTickedAt = DateTimeOffset.UtcNow;
} }
} }
/// <summary>
/// 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.
/// </summary>
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<GameClock>(_clockEntity).Ticks;
ClockSystem.Execute(_ecs, realElapsed);
var elapsedGameTicks = _ecs.Get<GameClock>(_clockEntity).Ticks - before;
if (elapsedGameTicks <= 0) return false;
var elapsedGame = TimeSpan.FromTicks(elapsedGameTicks);
var gameTime = new DateTime(_ecs.Get<GameClock>(_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<PressureSystem> 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() public WorldClockDto SnapshotClock()
{ {
lock (_gate) lock (_gate)
@@ -125,7 +252,7 @@ public sealed class WorldSimulation : IDisposable
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var gap = now - _lastTickedAt; var gap = now - _lastTickedAt;
if (gap > TimeSpan.Zero) ClockSystem.Execute(_ecs, gap); if (gap > TimeSpan.Zero) AdvanceUnlocked(gap);
ref var clock = ref _ecs.Get<GameClock>(_clockEntity); ref var clock = ref _ecs.Get<GameClock>(_clockEntity);
@@ -145,6 +272,77 @@ public sealed class WorldSimulation : IDisposable
} }
} }
/// <summary>Nodes per side of the weather grid served to the renderer.</summary>
public const int WeatherGridSize = 8;
/// <summary>Weather at the middle of the map - what the HUD and the world list show.</summary>
public WeatherDto SnapshotWeather()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
return SampleUnlocked(systems[..count], 0.5f, 0.5f);
}
}
/// <summary>
/// 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.
/// </summary>
public WeatherFieldDto SnapshotWeatherField()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Span<PressureSystem> 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<PressureSystem> systems, float x, float y)
{
var gameTime = new DateTime(_ecs.Get<GameClock>(_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) public WorldSummaryDto ApplyTo(WorldSummaryDto summary)
{ {
lock (_gate) lock (_gate)
@@ -153,21 +351,30 @@ public sealed class WorldSimulation : IDisposable
return summary with return summary with
{ {
Clock = SnapshotClockUnlocked(), Clock = SnapshotClockUnlocked(),
Climate = _climate.Kind,
LastTickedAt = _lastTickedAt, LastTickedAt = _lastTickedAt,
WeatherState = SnapshotWeatherStateUnlocked(),
}; };
} }
} }
/// <summary>Wire-facing snapshot: live clock, no internal last-tick stamp.</summary> /// <summary>Wire-facing snapshot: live clock and weather, none of the storage-only bookkeeping.</summary>
public WorldSummaryDto OverlayForApi(WorldSummaryDto summary) public WorldSummaryDto OverlayForApi(WorldSummaryDto summary)
{ {
lock (_gate) lock (_gate)
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(_ecs, systems);
return summary with return summary with
{ {
Clock = SnapshotClockUnlocked(), Clock = SnapshotClockUnlocked(),
Climate = _climate.Kind,
Weather = SampleUnlocked(systems[..count], 0.5f, 0.5f),
LastTickedAt = null, LastTickedAt = null,
WeatherState = null,
}; };
} }
} }
@@ -44,14 +44,18 @@ public sealed class WorldSimulationHost(
_simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotClock() : null; _simulations.TryGetValue(id, out var simulation) ? simulation.SnapshotClock() : null;
/// <summary> /// <summary>
/// The one projection from stored/in-flight state to what clients see: overlays the live clock when the /// The one projection from stored/in-flight state to what clients see: overlays the live clock and
/// world is running and always drops <see cref="WorldSummaryDto.LastTickedAt"/>, which is storage-only. /// weather when the world is running, and always drops the storage-only fields
/// (<see cref="WorldSummaryDto.LastTickedAt"/>, <see cref="WorldSummaryDto.WeatherState"/>).
/// Every endpoint that returns a summary must go through here. /// Every endpoint that returns a summary must go through here.
/// </summary> /// </summary>
public WorldSummaryDto Overlay(WorldSummaryDto summary) => public WorldSummaryDto Overlay(WorldSummaryDto summary) =>
_simulations.TryGetValue(summary.Id, out var simulation) _simulations.TryGetValue(summary.Id, out var simulation)
? simulation.OverlayForApi(summary) ? 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) public WorldClockDto UpdateClock(string id, UpdateClockRequest request)
{ {
@@ -1,3 +1,5 @@
using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Core.Contracts; namespace TheLivingWorld.Core.Contracts;
public enum WorldStatus 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. /// Naive in-world calendar start. When omitted, defaults to 12 April 2012 06:00.
/// </summary> /// </summary>
public DateTime? StartGameTime { get; init; } public DateTime? StartGameTime { get; init; }
/// <summary>
/// Climate driving the weather. When omitted it is guessed from <see cref="Latitude"/>, which is right
/// often enough to be a good default and wrong often enough to stay overridable.
/// </summary>
public ClimateKind? Climate { get; init; }
} }
/// <summary>Response body for <c>GET /api/worlds</c>.</summary> /// <summary>Response body for <c>GET /api/worlds</c>.</summary>
@@ -65,11 +73,61 @@ public sealed record WorldSummaryDto
/// <summary>In-world calendar and playback controls. Present once the world exists; frozen until Ready.</summary> /// <summary>In-world calendar and playback controls. Present once the world exists; frozen until Ready.</summary>
public WorldClockDto? Clock { get; init; } public WorldClockDto? Clock { get; init; }
/// <summary>Climate driving this world's weather. Null only for worlds created before climates existed.</summary>
public ClimateKind? Climate { get; init; }
/// <summary>
/// Live weather at the centre of the map, for the HUD and the world list. The full field lives behind
/// <c>GET /api/worlds/{id}/weather</c>; only Ready worlds that are actually running carry this.
/// </summary>
public WeatherDto? Weather { get; init; }
/// <summary> /// <summary>
/// Wall-clock moment of the last simulation tick. Persisted in <c>state.json</c> for catch-up after /// Wall-clock moment of the last simulation tick. Persisted in <c>state.json</c> for catch-up after
/// restart; stripped from API responses (clients see live <see cref="Clock"/> only). /// restart; stripped from API responses (clients see live <see cref="Clock"/> only).
/// </summary> /// </summary>
public DateTimeOffset? LastTickedAt { get; init; } public DateTimeOffset? LastTickedAt { get; init; }
/// <summary>
/// The drifting pressure systems as they stood at the last persist, so a restart resumes the sky it had
/// instead of rolling a new one. Storage-only, stripped from API responses like <see cref="LastTickedAt"/>.
/// </summary>
public WeatherStateDto? WeatherState { get; init; }
}
/// <summary>Storage shape of a world's live weather. Never leaves the server.</summary>
public sealed record WeatherStateDto
{
/// <summary>PRNG state, so respawned systems continue the world's sequence rather than restarting it.</summary>
public required ulong RngState { get; init; }
/// <summary>Lying snow. Has to be stored: it is the accumulated past, not a function of the present.</summary>
public float SnowDepthMm { get; init; }
public required IReadOnlyList<PressureSystemDto> Systems { get; init; }
}
/// <summary>
/// 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 <c>state.json</c>.
/// </summary>
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; }
} }
/// <summary>Live game calendar for a world. Game time is naive local calendar time, not UTC.</summary> /// <summary>Live game calendar for a world. Game time is naive local calendar time, not UTC.</summary>
@@ -83,6 +141,72 @@ public sealed record WorldClockDto
public required bool Paused { get; init; } public required bool Paused { get; init; }
} }
/// <summary>Weather at one point. Values are rounded on the way out - nobody needs 14 digits of humidity.</summary>
public sealed record WeatherDto
{
public required WeatherCondition Condition { get; init; }
public required double TemperatureC { get; init; }
/// <summary>Wind chill in the cold, humidex in the heat, plain temperature in between.</summary>
public required double FeelsLikeC { get; init; }
public required double PressureHpa { get; init; }
/// <summary>Relative humidity, 0..1.</summary>
public required double Humidity { get; init; }
/// <summary>Fraction of sky covered, 0..1. Drives the overcast tint in the renderer.</summary>
public required double CloudCover { get; init; }
public required double PrecipitationMmH { get; init; }
public required double WindSpeedMs { get; init; }
/// <summary>Compass bearing the wind blows <em>from</em>, 0..360.</summary>
public required double WindDirectionDeg { get; init; }
/// <summary>
/// Snow lying on the ground. World-wide rather than per point: over ten kilometres the cover really is
/// uniform, and it is the one weather value with memory, so it is integrated rather than sampled.
/// </summary>
public required double SnowDepthMm { get; init; }
}
/// <summary>
/// Response body for <c>GET /api/worlds/{id}/weather</c>: 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.
/// </summary>
public sealed record WeatherFieldDto
{
public required ClimateKind Climate { get; init; }
/// <summary>Nodes per side. <c>Nodes</c> holds <c>Size * Size</c> entries.</summary>
public required int Size { get; init; }
public required IReadOnlyList<WeatherDto> Nodes { get; init; }
}
/// <summary>One entry of <c>GET /api/climates</c>, so the create form never drifts from the server's list.</summary>
public sealed record ClimateDto
{
public required ClimateKind Kind { get; init; }
public required string Label { get; init; }
/// <summary>Köppen code, e.g. <c>Dfb</c>.</summary>
public required string KoppenCode { get; init; }
/// <summary>A real place that feels like this.</summary>
public required string Example { get; init; }
/// <summary>
/// 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.
/// </summary>
public double? BandLimit { get; init; }
}
/// <summary>Request body for <c>PATCH /api/worlds/{id}/clock</c>. Omitted fields keep their current value.</summary> /// <summary>Request body for <c>PATCH /api/worlds/{id}/clock</c>. Omitted fields keep their current value.</summary>
public sealed record UpdateClockRequest public sealed record UpdateClockRequest
{ {
+22
View File
@@ -36,3 +36,25 @@ public record struct DisplayName(string? Value);
/// naive (unspecified) calendar — local morning in the town, not UTC. /// naive (unspecified) calendar — local morning in the town, not UTC.
/// </summary> /// </summary>
public record struct GameClock(long Ticks, int TimeScale, bool Paused); public record struct GameClock(long Ticks, int TimeScale, bool Paused);
/// <summary>
/// One drifting cyclone (negative <see cref="IntensityHpa"/>) 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.
/// </summary>
public record struct PressureSystem(
float X,
float Y,
float VelocityX,
float VelocityY,
float IntensityHpa,
float Radius,
float AgeHours,
float LifetimeHours);
/// <summary>
/// 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.
/// </summary>
public record struct WeatherState(ulong RngState, float SnowDepthMm);
@@ -0,0 +1,63 @@
namespace TheLivingWorld.Core.Simulation;
/// <summary>
/// Köppen-lite climate presets. Twelve buckets that cover every recognisable place on Earth without asking a
/// player to pick from thirty classes.
/// </summary>
public enum ClimateKind
{
/// <summary>Af - rain all year, barely any seasons. Singapore.</summary>
Equatorial = 0,
/// <summary>Am - a hot dry season broken by a violent wet one. Mumbai.</summary>
TropicalMonsoon,
/// <summary>Aw - savanna: warm year round, rain in summer only. Nairobi.</summary>
Savanna,
/// <summary>BWh - hot desert: enormous day/night swing, almost no rain. Cairo.</summary>
HotDesert,
/// <summary>BSk - cold steppe: dry, continental, windy. Astana.</summary>
ColdSteppe,
/// <summary>Csa - dry hot summer, mild wet winter. Barcelona.</summary>
Mediterranean,
/// <summary>Cfa - humid subtropical: muggy summers, cool winters. Tokyo.</summary>
HumidSubtropical,
/// <summary>Cfb - oceanic: narrow temperature range, grey and wet. London.</summary>
Oceanic,
/// <summary>Dfb - warm-summer continental. Warsaw, and most of central Europe.</summary>
CentralEuropean,
/// <summary>Dfc - subarctic continental: brutal winters, short warm summers. Yakutsk.</summary>
Siberian,
/// <summary>ET - tundra: nothing ever really warms up. Murmansk.</summary>
Tundra,
/// <summary>H - highland: thin air, huge diurnal swing, mild annual range. La Paz.</summary>
Highland,
}
/// <summary>What the sky is doing right now, as a single label the UI can show.</summary>
public enum WeatherCondition
{
Clear = 0,
FewClouds,
Cloudy,
Overcast,
Fog,
Drizzle,
Rain,
HeavyRain,
Thunderstorm,
Sleet,
Snow,
HeavySnow,
Blizzard,
Sandstorm,
}
@@ -0,0 +1,357 @@
namespace TheLivingWorld.Core.Simulation;
/// <summary>
/// The tunable profile behind one <see cref="ClimateKind"/>. Everything the weather model needs to turn a
/// game calendar instant into a plausible sky, and nothing else.
/// </summary>
public sealed record ClimatePreset
{
public required ClimateKind Kind { get; init; }
/// <summary>Köppen code, shown in the UI so the choice is recognisable to anyone who knows the system.</summary>
public required string KoppenCode { get; init; }
public required string Label { get; init; }
/// <summary>A real place that feels like this, to make the list pickable without reading numbers.</summary>
public required string Example { get; init; }
/// <summary>Mean annual temperature at sea level, °C.</summary>
public required float MeanTemperatureC { get; init; }
/// <summary>Full winter-to-summer swing of the monthly mean, °C.</summary>
public required float AnnualRangeC { get; init; }
/// <summary>Full night-to-afternoon swing under a clear sky, °C. Clouds damp this.</summary>
public required float DiurnalRangeC { get; init; }
/// <summary>Baseline relative humidity, 0..1, before the pressure field pushes it around.</summary>
public required float Humidity { get; init; }
/// <summary>Baseline wind speed, m/s, before the pressure gradient adds to it.</summary>
public required float WindSpeedMs { get; init; }
/// <summary>How readily cloud turns into actual precipitation, 0..1.</summary>
public required float Wetness { get; init; }
/// <summary>
/// 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 <see cref="Seasonality"/> is zero.
/// </summary>
public required float WetSeasonPhase { get; init; }
/// <summary>How much the wet season matters, 0 = rain is spread evenly, 1 = one soaking season.</summary>
public required float Seasonality { get; init; }
/// <summary>Concurrent pressure systems the model keeps alive. More systems = faster changing weather.</summary>
public required int Storminess { get; init; }
/// <summary>Convective climates spawn thunderstorms; stable ones just rain.</summary>
public required float Convectivity { get; init; }
}
/// <summary>The twelve presets, plus the rule that guesses one from a latitude.</summary>
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,
};
/// <summary>Every preset, in the order the picker should show them: hot to cold.</summary>
public static readonly IReadOnlyList<ClimatePreset> All =
[
Equatorial,
TropicalMonsoon,
Savanna,
HotDesert,
ColdSteppe,
Mediterranean,
HumidSubtropical,
Oceanic,
CentralEuropean,
Siberian,
Tundra,
Highland,
];
private static readonly Dictionary<ClimateKind, ClimatePreset> 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.");
/// <summary>
/// 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
/// (<see cref="ClimateKind.TropicalMonsoon"/>, <see cref="ClimateKind.ColdSteppe"/>,
/// <see cref="ClimateKind.Highland"/>) are never guessed and stay a deliberate choice.
/// <para>
/// 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.
/// </para>
/// </summary>
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;
}
/// <summary>
/// Absolute latitude below which <see cref="FromLatitude"/> 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.
/// </summary>
public static double? BandLimit(ClimateKind kind)
{
foreach (var (limit, banded) in Bands)
{
if (banded == kind) return limit;
}
return null;
}
/// <summary>
/// The presets <see cref="FromLatitude"/> can actually produce. The rest exist only because a player
/// asked for them, and the UI says so rather than leaving them looking broken.
/// </summary>
public static bool IsInferable(ClimateKind kind) => BandLimit(kind) is not null;
/// <summary>
/// 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.
/// </summary>
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),
];
}
@@ -0,0 +1,40 @@
namespace TheLivingWorld.Core.Simulation;
/// <summary>
/// splitmix64. Chosen because its entire state is one <see cref="ulong"/>, which means a world's weather can
/// be persisted and resumed exactly rather than re-rolled on every restart.
/// </summary>
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);
}
/// <summary>Uniform in [0, 1).</summary>
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;
/// <summary>A stable seed for a world id, so the same world always gets the same weather sequence.</summary>
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;
}
}
@@ -0,0 +1,33 @@
using Arch.Core;
using TheLivingWorld.Core.Ecs;
namespace TheLivingWorld.Core.Simulation;
/// <summary>
/// Registers every component a live world uses, once, before any of them can be touched concurrently.
/// <para>
/// 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.
/// </para>
/// <para>
/// A static constructor is the fix: the CLR guarantees it runs exactly once and blocks every other thread
/// until it finishes.
/// </para>
/// </summary>
public static class SimulationComponents
{
static SimulationComponents()
{
var probe = World.Create();
probe.Create(new GameClock(), new PressureSystem(), new WeatherState());
World.Destroy(probe);
}
/// <summary>Touches this type, which forces the static constructor above to have run.</summary>
public static void EnsureRegistered()
{
}
}
@@ -0,0 +1,344 @@
using TheLivingWorld.Core.Ecs;
namespace TheLivingWorld.Core.Simulation;
/// <summary>Everything the weather looks like at one point of one world at one instant.</summary>
public readonly record struct WeatherSample
{
public required float TemperatureC { get; init; }
/// <summary>Wind chill below 10 °C, heat index above 26 °C, plain temperature in between.</summary>
public required float FeelsLikeC { get; init; }
public required float PressureHpa { get; init; }
/// <summary>Relative humidity, 0..1.</summary>
public required float Humidity { get; init; }
/// <summary>Fraction of the sky covered, 0..1.</summary>
public required float CloudCover { get; init; }
public required float PrecipitationMmH { get; init; }
public required float WindSpeedMs { get; init; }
/// <summary>Meteorological convention: the compass bearing the wind blows <em>from</em>, 0..360.</summary>
public required float WindDirectionDeg { get; init; }
public required WeatherCondition Condition { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public static class WeatherModel
{
public const float SeaLevelPressureHpa = 1013.25f;
/// <summary>Northern hemisphere peak warmth, ~19 July: the calendar lags the solstice by about a month.</summary>
private const float WarmestDayOfYear = 200f;
/// <summary>Afternoon peak, lagging solar noon for the same reason.</summary>
private const float WarmestHour = 15f;
private const float DaysPerYear = 365.2425f;
/// <summary>-1 at midwinter, +1 at midsummer, flipped below the equator.</summary>
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;
}
/// <summary>-1 at the coldest hour before dawn, +1 mid-afternoon.</summary>
public static float DiurnalPhase(DateTime gameTime)
{
var hour = (float)gameTime.TimeOfDay.TotalHours;
return MathF.Cos((hour - WarmestHour) / 24f * 2f * MathF.PI);
}
/// <summary>
/// How wet this part of the year is for a climate: 1 is the annual average, higher in the wet season.
/// Climates with no <see cref="ClimatePreset.Seasonality"/> sit flat at 1 all year.
/// </summary>
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);
}
/// <summary>
/// Turns a sampled pressure field into a full weather reading.
/// </summary>
/// <param name="anomalyHpa">Pressure departure from the standard atmosphere at this point.</param>
/// <param name="gradientX">Pressure change per unit of normalised world space, eastwards.</param>
/// <param name="gradientY">Same, northwards.</param>
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);
}
/// <summary>Compass bearing the wind arrives from, which is the reverse of where it is heading.</summary>
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;
}
/// <summary>Above this the sky rains rather than snows, and lying snow starts to go.</summary>
private const float FreezingC = 0.5f;
/// <summary>Fresh snow is mostly air: a millimetre of water lands as roughly a centimetre of snow.</summary>
private const float SnowWaterRatio = 10f;
/// <summary>Millimetres of snow lost per hour per degree above freezing.</summary>
private const float MeltRateMmPerDegreeHour = 1.6f;
public const float MaxSnowDepthMm = 600f;
/// <summary>Lying snow deep enough to have covered everything, used to normalise the renderer's wash.</summary>
public const float FullCoverDepthMm = 120f;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// A plausible depth of lying snow for a climate at this point in the year, without simulating the
/// winter that produced it. Used when a world is created and when one comes back from a long absence:
/// a Siberian world opened in January should already be under snow, not waiting for the first fall.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
public static (float Anomaly, float GradientX, float GradientY) SampleField(
ReadOnlySpan<PressureSystem> 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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -0,0 +1,229 @@
using Arch.Core;
using TheLivingWorld.Core.Ecs;
namespace TheLivingWorld.Core.Simulation;
/// <summary>
/// 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.
/// </summary>
public static class WeatherSystem
{
/// <summary>Upper bound on <see cref="ClimatePreset.Storminess"/>, and the size of every stack buffer here.</summary>
public const int MaxSystems = 8;
private static readonly QueryDescription Systems = new QueryDescription().WithAll<PressureSystem>();
/// <summary>
/// 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.
/// </summary>
private const float MinDriftPerHour = 0.035f;
private const float MaxDriftPerHour = 0.11f;
/// <summary>Beyond this distance from the map a system is spent and gets recycled.</summary>
private const float OffMapLimit = 1.9f;
/// <summary>Creates the weather state entity and the pool of pressure systems for a fresh world.</summary>
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)));
}
/// <summary>
/// Restores a persisted sky. Falls back to <see cref="Seed"/> when the stored pool is empty, so a world
/// written before weather existed still gets one.
/// </summary>
public static void Restore(
World ecs,
ClimatePreset climate,
double latitude,
ulong fallbackSeed,
DateTime gameTime,
ulong rngState,
float snowDepthMm,
ReadOnlySpan<PressureSystem> 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)));
}
/// <summary>
/// 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.
/// </summary>
public static void Reseed(World ecs, ClimatePreset climate, double latitude, DateTime gameTime)
{
Span<Entity> 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<PressureSystem>(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);
}
/// <summary>Advances every pressure system, recycling the ones that have blown through or died out.</summary>
public static void Execute(World ecs, ClimatePreset climate, double latitude, float elapsedGameHours)
{
if (elapsedGameHours <= 0f) return;
Span<Entity> 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<PressureSystem>(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;
}
/// <summary>The PRNG state, so it can be persisted alongside the systems it will spawn next.</summary>
public static ulong RngState(World ecs) => StateOf(ecs).RngState;
public static float SnowDepthMm(World ecs) => StateOf(ecs).SnowDepthMm;
/// <summary>
/// Piles up or melts the lying snow for one step, given what the sky was doing over the map.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public static int CopySystems(World ecs, Span<PressureSystem> destination)
{
Span<Entity> entities = stackalloc Entity[MaxSystems];
var count = Math.Min(Gather(ecs, entities), destination.Length);
for (var i = 0; i < count; i++)
destination[i] = ecs.Get<PressureSystem>(entities[i]);
return count;
}
/// <summary>
/// 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.
/// </summary>
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));
}
/// <summary>
/// Fills <paramref name="destination"/> 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.
/// </summary>
private static int Gather(World ecs, Span<Entity> 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<WeatherState>();
if (ecs.CountEntities(in description) == 0)
throw new InvalidOperationException("World has no weather state entity.");
Span<Entity> holder = stackalloc Entity[1];
ecs.GetEntities(in description, holder);
return ref ecs.Get<WeatherState>(holder[0]);
}
}
+9
View File
@@ -78,6 +78,14 @@
/> />
</label> </label>
<label class="field">
<span>Climate</span>
<select id="field-climate">
<option value="">From the location</option>
</select>
<small id="climate-hint" class="field__hint"></small>
</label>
<p id="form-hint" class="form__hint" hidden></p> <p id="form-hint" class="form__hint" hidden></p>
<button id="generate-button" type="submit" class="button">Generate world</button> <button id="generate-button" type="submit" class="button">Generate world</button>
</form> </form>
@@ -97,6 +105,7 @@
<span id="world-title" class="game-bar__title"></span> <span id="world-title" class="game-bar__title"></span>
<div id="sim-controls" class="sim-controls" hidden> <div id="sim-controls" class="sim-controls" hidden>
<span id="game-clock" class="sim-controls__clock" aria-live="polite"></span> <span id="game-clock" class="sim-controls__clock" aria-live="polite"></span>
<span id="game-weather" class="sim-controls__weather" aria-live="polite"></span>
<button <button
id="play-pause" id="play-pause"
type="button" type="button"
+6
View File
@@ -1,7 +1,9 @@
import type { import type {
ClimateOption,
CreateWorldRequest, CreateWorldRequest,
MapChunk, MapChunk,
UpdateClockRequest, UpdateClockRequest,
WeatherField,
WorldClock, WorldClock,
WorldList, WorldList,
WorldMap, WorldMap,
@@ -55,6 +57,10 @@ export const api = {
getChunk: (id: string, x: number, y: number, signal?: AbortSignal) => getChunk: (id: string, x: number, y: number, signal?: AbortSignal) =>
request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined), request<MapChunk>(`${BASE}/${id}/chunks/${x}/${y}`, signal ? { signal } : undefined),
listClimates: () => request<ClimateOption[]>('/api/climates'),
getWeather: (id: string) => request<WeatherField>(`${BASE}/${id}/weather`),
updateClock: (id: string, body: UpdateClockRequest) => updateClock: (id: string, body: UpdateClockRequest) =>
request<WorldClock>(`${BASE}/${id}/clock`, { request<WorldClock>(`${BASE}/${id}/clock`, {
method: 'PATCH', method: 'PATCH',
+51
View File
@@ -22,6 +22,55 @@ export interface WorldSummary {
createdAt: string; createdAt: string;
stats?: WorldStats; stats?: WorldStats;
clock?: WorldClock; clock?: WorldClock;
climate?: ClimateKind;
/** Live weather at the middle of the map. Only present while the world is running. */
weather?: Weather;
}
/** Köppen-lite climate presets. Mirrors ClimateKind on the server. */
export type ClimateKind =
| 'equatorial' | 'tropicalMonsoon' | 'savanna' | 'hotDesert' | 'coldSteppe' | 'mediterranean'
| 'humidSubtropical' | 'oceanic' | 'centralEuropean' | 'siberian' | 'tundra' | 'highland';
export type WeatherCondition =
| 'clear' | 'fewClouds' | 'cloudy' | 'overcast' | 'fog' | 'drizzle' | 'rain' | 'heavyRain'
| 'thunderstorm' | 'sleet' | 'snow' | 'heavySnow' | 'blizzard' | 'sandstorm';
export interface Weather {
condition: WeatherCondition;
temperatureC: number;
feelsLikeC: number;
pressureHpa: number;
/** 0..1 */
humidity: number;
/** 0..1 */
cloudCover: number;
precipitationMmH: number;
windSpeedMs: number;
/** Compass bearing the wind blows from, 0..360. */
windDirectionDeg: number;
/** Snow lying on the ground. World-wide rather than per point, so every node carries the same value. */
snowDepthMm: number;
}
/** Response body for GET /api/worlds/{id}/weather: a square grid, row-major from the south-west corner. */
export interface WeatherField {
climate: ClimateKind;
size: number;
nodes: Weather[];
}
/** One entry of GET /api/climates. */
export interface ClimateOption {
kind: ClimateKind;
label: string;
koppenCode: string;
example: string;
/**
* Absolute latitude below which this preset is the server's default, or absent when the latitude rule
* never picks it. Entries arrive equator-first.
*/
bandLimit?: number;
} }
/** In-world calendar. gameTime is a naive local datetime string (no Z). */ /** In-world calendar. gameTime is a naive local datetime string (no Z). */
@@ -50,6 +99,8 @@ export interface CreateWorldRequest {
forceRefresh?: boolean; forceRefresh?: boolean;
/** Naive local game calendar start, e.g. `2012-04-12T06:00:00`. Omits → server default. */ /** Naive local game calendar start, e.g. `2012-04-12T06:00:00`. Omits → server default. */
startGameTime?: string; startGameTime?: string;
/** Omit to let the server guess from the latitude. */
climate?: ClimateKind;
} }
/** [minX, minY, maxX, maxY] in world metres. */ /** [minX, minY, maxX, maxY] in world metres. */
+113 -3
View File
@@ -1,21 +1,26 @@
import './styles.css'; import './styles.css';
import { api, waitForWorld } from './api/client'; import { api, waitForWorld } from './api/client';
import type { WorldClock, WorldSummary } from './api/types'; import type { ClimateKind, ClimateOption, Weather, WorldClock, WorldSummary } from './api/types';
import { MapView, type MapStatus } from './map/mapView'; import { MapView, type MapStatus } from './map/mapView';
import { THEMES, type ThemeName } from './map/theme'; import { THEMES, type ThemeName } from './map/theme';
import { formatCoordinates, parseCoordinates } from './ui/coordinates'; import { formatCoordinates, parseCoordinates } from './ui/coordinates';
import { import {
formatGameTime, formatGameTime,
formatGameTimeRaw, formatGameTimeRaw,
formatWeekday,
interpolateGameTime, interpolateGameTime,
startGameTimeFromInput, startGameTimeFromInput,
} from './ui/gameTime'; } from './ui/gameTime';
import { climateFromLatitude, describeClimate, findClimate } from './ui/climate';
import { describeWeather, formatWeather } from './ui/weather';
const LAST_WORLD_KEY = 'the-living-world:last-world'; const LAST_WORLD_KEY = 'the-living-world:last-world';
const THEME_KEY = 'the-living-world:theme'; const THEME_KEY = 'the-living-world:theme';
const MENU_POLL_MS = 2000; const MENU_POLL_MS = 2000;
const GAME_POLL_MS = 1000; const GAME_POLL_MS = 1000;
const CLOCK_PAINT_MS = 250; const CLOCK_PAINT_MS = 250;
/** The weather field is 64 samples and drifts slowly, so it does not deserve the clock's cadence. */
const WEATHER_POLL_MS = 3000;
const elements = { const elements = {
menu: required<HTMLDivElement>('menu'), menu: required<HTMLDivElement>('menu'),
@@ -27,6 +32,8 @@ const elements = {
size: required<HTMLInputElement>('field-size'), size: required<HTMLInputElement>('field-size'),
sizeValue: required<HTMLOutputElement>('field-size-value'), sizeValue: required<HTMLOutputElement>('field-size-value'),
start: required<HTMLInputElement>('field-start'), start: required<HTMLInputElement>('field-start'),
climate: required<HTMLSelectElement>('field-climate'),
climateHint: required<HTMLElement>('climate-hint'),
generate: required<HTMLButtonElement>('generate-button'), generate: required<HTMLButtonElement>('generate-button'),
formHint: required<HTMLElement>('form-hint'), formHint: required<HTMLElement>('form-hint'),
useLocation: required<HTMLButtonElement>('use-location'), useLocation: required<HTMLButtonElement>('use-location'),
@@ -43,6 +50,7 @@ const elements = {
gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'), gameThemeToggle: required<HTMLButtonElement>('game-theme-toggle'),
simControls: required<HTMLDivElement>('sim-controls'), simControls: required<HTMLDivElement>('sim-controls'),
gameClock: required<HTMLElement>('game-clock'), gameClock: required<HTMLElement>('game-clock'),
gameWeather: required<HTMLElement>('game-weather'),
playPause: required<HTMLButtonElement>('play-pause'), playPause: required<HTMLButtonElement>('play-pause'),
speedButtons: [...document.querySelectorAll<HTMLButtonElement>('.speed-button')], speedButtons: [...document.querySelectorAll<HTMLButtonElement>('.speed-button')],
}; };
@@ -62,9 +70,12 @@ let menuPollTimer: number | null = null;
let menuPaintTimer: number | null = null; let menuPaintTimer: number | null = null;
let gameClock: WorldClock | null = null; let gameClock: WorldClock | null = null;
let gameWeather: Weather | null = null;
let climateOptions: ClimateOption[] = [];
let gameSnapshotAt = 0; let gameSnapshotAt = 0;
let gamePollTimer: number | null = null; let gamePollTimer: number | null = null;
let gamePaintTimer: number | null = null; let gamePaintTimer: number | null = null;
let weatherPollTimer: number | null = null;
let clockUpdating = false; let clockUpdating = false;
function required<T extends HTMLElement>(id: string): T { function required<T extends HTMLElement>(id: string): T {
@@ -309,13 +320,32 @@ function applyClockToControls(clock: WorldClock): void {
function paintGameClock(): void { function paintGameClock(): void {
if (!gameClock) { if (!gameClock) {
elements.gameClock.textContent = ''; elements.gameClock.textContent = '';
elements.gameClock.title = '';
view.setGameTime(null);
return; return;
} }
const date = interpolateGameTime(gameClock, performance.now() - gameSnapshotAt); const date = interpolateGameTime(gameClock, performance.now() - gameSnapshotAt);
elements.gameClock.textContent = date elements.gameClock.textContent = date
? formatGameTime(date) ? formatGameTime(date, { weekday: true })
: formatGameTimeRaw(gameClock.gameTime); : formatGameTimeRaw(gameClock.gameTime, { weekday: true });
elements.gameClock.title = date ? formatWeekday(date) : '';
// The renderer lights the sky from the same interpolated instant the HUD shows, so the two never disagree.
view.setGameTime(date);
}
function applyWeatherToControls(weather: Weather | undefined): void {
gameWeather = weather ?? null;
if (!gameWeather) {
elements.gameWeather.textContent = '';
elements.gameWeather.title = '';
return;
}
elements.gameWeather.textContent = formatWeather(gameWeather);
elements.gameWeather.title = describeWeather(gameWeather);
} }
function startGameClockLoop(worldId: string): void { function startGameClockLoop(worldId: string): void {
@@ -325,6 +355,11 @@ function startGameClockLoop(worldId: string): void {
void pollGameClock(worldId); void pollGameClock(worldId);
}, GAME_POLL_MS); }, GAME_POLL_MS);
gamePaintTimer = window.setInterval(paintGameClock, CLOCK_PAINT_MS); gamePaintTimer = window.setInterval(paintGameClock, CLOCK_PAINT_MS);
void pollWeatherField(worldId);
weatherPollTimer = window.setInterval(() => {
void pollWeatherField(worldId);
}, WEATHER_POLL_MS);
} }
function stopGameClockLoop(): void { function stopGameClockLoop(): void {
@@ -336,9 +371,28 @@ function stopGameClockLoop(): void {
window.clearInterval(gamePaintTimer); window.clearInterval(gamePaintTimer);
gamePaintTimer = null; gamePaintTimer = null;
} }
if (weatherPollTimer !== null) {
window.clearInterval(weatherPollTimer);
weatherPollTimer = null;
}
gameClock = null; gameClock = null;
gameWeather = null;
view.setWeatherField(null);
view.setGameTime(null);
elements.simControls.hidden = true; elements.simControls.hidden = true;
elements.gameClock.textContent = ''; elements.gameClock.textContent = '';
elements.gameWeather.textContent = '';
}
async function pollWeatherField(worldId: string): Promise<void> {
if (activeWorldId !== worldId) return;
try {
const field = await api.getWeather(worldId);
if (activeWorldId === worldId) view.setWeatherField(field);
} catch {
// Keep drawing the last field; a missed poll is not worth clearing the sky for.
}
} }
async function pollGameClock(worldId: string): Promise<void> { async function pollGameClock(worldId: string): Promise<void> {
@@ -348,6 +402,9 @@ async function pollGameClock(worldId: string): Promise<void> {
const summary = await api.getWorld(worldId); const summary = await api.getWorld(worldId);
if (activeWorldId !== worldId || !summary.clock) return; if (activeWorldId !== worldId || !summary.clock) return;
applyClockToControls(summary.clock); applyClockToControls(summary.clock);
// Weather rides along with the clock poll: it moves far slower than the clock, so it needs no
// interpolation of its own.
applyWeatherToControls(summary.weather);
} catch { } catch {
// Keep interpolating from the last good snapshot. // Keep interpolating from the last good snapshot.
} }
@@ -367,6 +424,53 @@ async function patchClock(body: { paused?: boolean; timeScale?: number }): Promi
} }
} }
async function loadClimates(): Promise<void> {
try {
climateOptions = await api.listClimates();
} catch {
// The picker is a convenience: without it the server still guesses from the location.
climateOptions = [];
}
for (const option of climateOptions) {
const element = document.createElement('option');
element.value = option.kind;
element.textContent = describeClimate(option);
elements.climate.append(element);
}
elements.climate.disabled = climateOptions.length === 0;
paintClimateHint();
}
/** Explains what "From the location" will actually pick, and warns when a manual choice fights the latitude. */
function paintClimateHint(): void {
if (climateOptions.length === 0) {
elements.climateHint.textContent = '';
return;
}
const location = parseCoordinates(elements.coords.value);
const inferred = location ? findClimate(climateOptions, climateFromLatitude(climateOptions, location.latitude)) : null;
if (!elements.climate.value) {
elements.climateHint.textContent = inferred
? `This location suggests ${inferred.label} (${inferred.koppenCode}).`
: 'Enter a location to see what the latitude suggests.';
return;
}
const chosen = findClimate(climateOptions, elements.climate.value as ClimateKind);
if (!chosen) {
elements.climateHint.textContent = '';
return;
}
elements.climateHint.textContent = inferred && inferred.kind !== chosen.kind
? `${chosen.label} — the latitude would have suggested ${inferred.label}.`
: `${chosen.label}, like ${chosen.example}.`;
}
async function ensureMap(): Promise<void> { async function ensureMap(): Promise<void> {
if (mapReady) return; if (mapReady) return;
await view.init(elements.stage); await view.init(elements.stage);
@@ -393,6 +497,7 @@ async function openWorld(id: string): Promise<void> {
setStatus(''); setStatus('');
if (summary.clock) applyClockToControls(summary.clock); if (summary.clock) applyClockToControls(summary.clock);
applyWeatherToControls(summary.weather);
startGameClockLoop(id); startGameClockLoop(id);
await refreshWorldList(); await refreshWorldList();
@@ -471,6 +576,8 @@ async function generate(event: SubmitEvent): Promise<void> {
longitude: location.longitude, longitude: location.longitude,
sizeKm, sizeKm,
startGameTime, startGameTime,
// Empty means "from the location": leave it out and let the server apply its own rule.
climate: (elements.climate.value as ClimateKind) || undefined,
}); });
await refreshWorldList(); await refreshWorldList();
@@ -550,6 +657,8 @@ async function start(): Promise<void> {
void generate(event); void generate(event);
}); });
elements.useLocation.addEventListener('click', useMyLocation); elements.useLocation.addEventListener('click', useMyLocation);
elements.coords.addEventListener('input', paintClimateHint);
elements.climate.addEventListener('change', paintClimateHint);
elements.continue.addEventListener('click', () => { elements.continue.addEventListener('click', () => {
if (continueWorldId) void openWorld(continueWorldId); if (continueWorldId) void openWorld(continueWorldId);
}); });
@@ -572,6 +681,7 @@ async function start(): Promise<void> {
applyTheme(readStoredTheme()); applyTheme(readStoredTheme());
showMenu(); showMenu();
await loadClimates();
try { try {
const worlds = await refreshWorldList(); const worlds = await refreshWorldList();
+63 -1
View File
@@ -1,11 +1,14 @@
import { Application, Container, Graphics } from 'pixi.js'; import { Application, Container, Graphics } from 'pixi.js';
import type { WorldMap } from '../api/types'; import type { WeatherField, WorldMap } from '../api/types';
import { Camera, type Viewport } from './camera'; import { Camera, type Viewport } from './camera';
import { ChunkManager } from './chunkManager'; import { ChunkManager } from './chunkManager';
import { profileForZoom, type RenderProfile } from './chunkRenderer'; import { profileForZoom, type RenderProfile } from './chunkRenderer';
import { LabelLayer } from './labelLayer'; import { LabelLayer } from './labelLayer';
import { LAYER_ORDER, type MapLayers } from './layers'; import { LAYER_ORDER, type MapLayers } from './layers';
import { precipitationSpec, skyState } from './sky';
import { THEMES, type Theme, type ThemeName } from './theme'; import { THEMES, type Theme, type ThemeName } from './theme';
import { CALM, sampleWeatherField, type LocalWeather } from './weatherField';
import { WeatherLayer } from './weatherLayer';
/** /**
* Builds the container per layer. This lives here rather than in `layers.ts` so that module stays free of * Builds the container per layer. This lives here rather than in `layers.ts` so that module stays free of
@@ -49,6 +52,11 @@ export class MapView {
private theme: Theme = THEMES.day; private theme: Theme = THEMES.day;
private readonly labels = new LabelLayer(this.theme); private readonly labels = new LabelLayer(this.theme);
private readonly weather = new WeatherLayer();
private weatherField: WeatherField | null = null;
private gameTime: Date | null = null;
private latitude = 0;
private host: HTMLElement | null = null; private host: HTMLElement | null = null;
private worldSizeMeters = 0; private worldSizeMeters = 0;
@@ -80,9 +88,15 @@ export class MapView {
this.app.stage.addChild(this.root); this.app.stage.addChild(this.root);
// The wash goes over the map but under the place names, so a town stays readable at midnight.
this.app.stage.addChild(this.weather.sky);
// Labels sit outside the scaled container so they keep a constant size on screen. // Labels sit outside the scaled container so they keep a constant size on screen.
this.app.stage.addChild(this.labels.container); this.app.stage.addChild(this.labels.container);
// Rain falls in front of everything, labels included.
this.app.stage.addChild(this.weather.precipitation);
this.attachInput(this.app.canvas); this.attachInput(this.app.canvas);
// Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow. // Pixi resizes the canvas itself, but the container offset is derived from the viewport and has to follow.
@@ -96,7 +110,10 @@ export class MapView {
showWorld(map: WorldMap): void { showWorld(map: WorldMap): void {
this.chunks.clear(); this.chunks.clear();
this.labels.clear(); this.labels.clear();
this.weather.clear();
this.weatherField = null;
this.worldSizeMeters = map.sizeMeters; this.worldSizeMeters = map.sizeMeters;
this.latitude = map.latitude;
// Host may have just become visible after a menu → game switch; sync the renderer before fitting. // Host may have just become visible after a menu → game switch; sync the renderer before fitting.
this.app.resize(); this.app.resize();
@@ -128,17 +145,34 @@ export class MapView {
this.lastChunkUpdate = 0; this.lastChunkUpdate = 0;
} }
/** The weather field from the server. Sampled under the camera every frame, so a front crosses the map. */
setWeatherField(field: WeatherField | null): void {
this.weatherField = field;
}
/**
* The in-world instant the sky should be lit for. Pushed from the clock loop rather than read here, so
* there is one interpolated game clock in the app instead of two that can disagree.
*/
setGameTime(gameTime: Date | null): void {
this.gameTime = gameTime;
}
clear(): void { clear(): void {
this.chunks.clear(); this.chunks.clear();
this.labels.clear(); this.labels.clear();
this.weather.clear();
this.background.clear(); this.background.clear();
this.border.clear(); this.border.clear();
this.weatherField = null;
this.gameTime = null;
this.worldSizeMeters = 0; this.worldSizeMeters = 0;
} }
destroy(): void { destroy(): void {
this.chunks.clear(); this.chunks.clear();
this.labels.clear(); this.labels.clear();
this.weather.destroy();
this.app.destroy(true, { children: true }); this.app.destroy(true, { children: true });
} }
@@ -180,6 +214,8 @@ export class MapView {
this.chunks.processDrawQueue(this.profile); this.chunks.processDrawQueue(this.profile);
this.chunks.advanceFades(deltaMs); this.chunks.advanceFades(deltaMs);
this.updateWeather(viewport, deltaMs);
// Labels live in screen space, so they have to follow the camera every frame. Choosing them is the // Labels live in screen space, so they have to follow the camera every frame. Choosing them is the
// expensive half and stays on the timer; without this split they lag a fast pan and then snap back. // expensive half and stays on the timer; without this split they lag a fast pan and then snap back.
this.labels.reposition(this.camera, viewport); this.labels.reposition(this.camera, viewport);
@@ -195,6 +231,32 @@ export class MapView {
} }
} }
private updateWeather(viewport: Viewport, deltaMs: number): void {
this.weather.resize(viewport.width, viewport.height);
if (this.gameTime) {
const local = this.localWeather();
this.weather.apply(
skyState(this.gameTime, this.latitude, local, this.theme.dark),
precipitationSpec(local),
);
}
this.weather.advance(deltaMs);
}
/** The field read under the middle of the screen, so what falls is what is overhead right now. */
private localWeather(): LocalWeather {
if (!this.weatherField || this.worldSizeMeters === 0) return CALM;
const half = this.worldSizeMeters / 2;
return sampleWeatherField(
this.weatherField,
(this.camera.x + half) / this.worldSizeMeters,
(this.camera.y + half) / this.worldSizeMeters,
);
}
private drawBorder(): void { private drawBorder(): void {
const half = this.worldSizeMeters / 2; const half = this.worldSizeMeters / 2;
this.border this.border
+169
View File
@@ -0,0 +1,169 @@
import { describe, expect, it } from 'vitest';
import { FULL_SNOW_COVER_MM, precipitationSpec, skyState, sunElevationDeg } from './sky';
import { CALM, type LocalWeather } from './weatherField';
const WARSAW = 52.23;
const SYDNEY = -33.87;
function at(year: number, month: number, day: number, hour: number, minute = 0): Date {
return new Date(year, month - 1, day, hour, minute, 0);
}
function weather(overrides: Partial<LocalWeather> = {}): LocalWeather {
return { ...CALM, ...overrides };
}
describe('sunElevationDeg', () => {
it('peaks at local noon and bottoms out at midnight', () => {
const noon = sunElevationDeg(at(2012, 6, 21, 12), WARSAW);
const midnight = sunElevationDeg(at(2012, 6, 21, 0), WARSAW);
expect(noon).toBeGreaterThan(55);
expect(midnight).toBeLessThan(0);
});
it('is higher at midsummer than midwinter, and the other way round below the equator', () => {
expect(sunElevationDeg(at(2012, 6, 21, 12), WARSAW))
.toBeGreaterThan(sunElevationDeg(at(2012, 12, 21, 12), WARSAW));
expect(sunElevationDeg(at(2012, 6, 21, 12), SYDNEY))
.toBeLessThan(sunElevationDeg(at(2012, 12, 21, 12), SYDNEY));
});
it('keeps the sun up all night inside the arctic circle at midsummer', () => {
expect(sunElevationDeg(at(2012, 6, 21, 0), 78.22)).toBeGreaterThan(0);
// ...and down all day at midwinter.
expect(sunElevationDeg(at(2012, 12, 21, 12), 78.22)).toBeLessThan(0);
});
it('stays within the physically possible range everywhere, all year', () => {
for (const latitude of [-89, -45, 0, 45, 89]) {
for (let day = 1; day <= 365; day += 7) {
for (let hour = 0; hour < 24; hour += 3) {
const date = new Date(2012, 0, day, hour);
const elevation = sunElevationDeg(date, latitude);
expect(elevation).toBeGreaterThanOrEqual(-90);
expect(elevation).toBeLessThanOrEqual(90);
}
}
}
});
});
describe('skyState', () => {
it('leaves a clear midday alone', () => {
const state = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
expect(state.tintAlpha).toBeLessThan(0.02);
});
it('darkens through dusk into night', () => {
const noon = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false).tintAlpha;
const dusk = skyState(at(2012, 6, 21, 21), WARSAW, weather(), false).tintAlpha;
const night = skyState(at(2012, 12, 21, 0), WARSAW, weather(), false).tintAlpha;
expect(dusk).toBeGreaterThan(noon);
expect(night).toBeGreaterThan(dusk);
});
it('pulls the wash back when the map is already drawn dark', () => {
const time = at(2012, 12, 21, 0);
const onLight = skyState(time, WARSAW, weather(), false).tintAlpha;
const onDark = skyState(time, WARSAW, weather(), true).tintAlpha;
expect(onDark).toBeGreaterThan(0);
expect(onDark).toBeLessThan(onLight);
});
it('greys the light down under cloud, but only while the sun is up', () => {
const clearNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather(), false);
const cloudyNoon = skyState(at(2012, 6, 21, 12), WARSAW, weather({ cloudCover: 1 }), false);
expect(cloudyNoon.tintAlpha).toBeGreaterThan(clearNoon.tintAlpha);
const clearNight = skyState(at(2012, 12, 21, 0), WARSAW, weather(), false);
const cloudyNight = skyState(at(2012, 12, 21, 0), WARSAW, weather({ cloudCover: 1 }), false);
expect(cloudyNight.tintAlpha).toBeCloseTo(clearNight.tintAlpha, 5);
});
it('reports snow cover as a fraction that saturates', () => {
expect(skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: 0 }), false).snowCover).toBe(0);
expect(
skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: FULL_SNOW_COVER_MM / 2 }), false).snowCover,
).toBeCloseTo(0.5, 5);
expect(
skyState(at(2012, 1, 15, 12), WARSAW, weather({ snowDepthMm: 900 }), false).snowCover,
).toBe(1);
});
it('hazes over for fog and a blizzard, but not for plain rain', () => {
const time = at(2012, 4, 12, 6);
expect(skyState(time, WARSAW, weather({ condition: 'fog' }), false).hazeAlpha).toBeGreaterThan(0.3);
expect(skyState(time, WARSAW, weather({ condition: 'blizzard' }), false).hazeAlpha).toBeGreaterThan(0.3);
expect(skyState(time, WARSAW, weather({ condition: 'rain' }), false).hazeAlpha).toBe(0);
});
it('keeps every value inside its range across a whole year', () => {
for (let day = 1; day <= 365; day += 5) {
for (let hour = 0; hour < 24; hour += 2) {
const state = skyState(
new Date(2012, 0, day, hour),
WARSAW,
weather({ cloudCover: 1, snowDepthMm: 400, condition: 'blizzard' }),
false,
);
expect(state.tintAlpha).toBeGreaterThanOrEqual(0);
expect(state.tintAlpha).toBeLessThanOrEqual(1);
expect(state.snowCover).toBeLessThanOrEqual(1);
}
}
});
});
describe('precipitationSpec', () => {
it('reports nothing falling under a dry sky', () => {
expect(precipitationSpec(weather()).kind).toBe('none');
expect(precipitationSpec(weather({ precipitationMmH: 0.01 })).kind).toBe('none');
});
it('falls as rain above freezing and snow below it', () => {
expect(precipitationSpec(weather({ precipitationMmH: 3, temperatureC: 9 })).kind).toBe('rain');
expect(precipitationSpec(weather({ precipitationMmH: 3, temperatureC: -4 })).kind).toBe('snow');
});
it('sends more particles as the rain gets heavier, up to a ceiling', () => {
const light = precipitationSpec(weather({ precipitationMmH: 1, temperatureC: 9 }));
const heavy = precipitationSpec(weather({ precipitationMmH: 8, temperatureC: 9 }));
const absurd = precipitationSpec(weather({ precipitationMmH: 500, temperatureC: 9 }));
expect(heavy.density).toBeGreaterThan(light.density);
expect(absurd.density).toBeLessThanOrEqual(520);
});
it('leans the fall downwind, and the other way for the opposite wind', () => {
const fromWest = precipitationSpec(
weather({ precipitationMmH: 3, temperatureC: 9, windDirectionDeg: 270, windSpeedMs: 10 }),
);
const fromEast = precipitationSpec(
weather({ precipitationMmH: 3, temperatureC: 9, windDirectionDeg: 90, windSpeedMs: 10 }),
);
// A westerly blows towards the east, which is to the right of a north-up screen.
expect(fromWest.slantDeg).toBeGreaterThan(0);
expect(fromEast.slantDeg).toBeLessThan(0);
expect(fromWest.slantDeg).toBeCloseTo(-fromEast.slantDeg, 5);
});
it('never leans past the clamp, however hard it blows', () => {
const gale = precipitationSpec(
weather({ precipitationMmH: 5, temperatureC: 9, windDirectionDeg: 270, windSpeedMs: 90 }),
);
expect(gale.slantDeg).toBeLessThanOrEqual(62);
});
it('drops snow far more slowly than rain', () => {
const rain = precipitationSpec(weather({ precipitationMmH: 3, temperatureC: 9 }));
const snow = precipitationSpec(weather({ precipitationMmH: 3, temperatureC: -4 }));
expect(snow.speedPxPerSecond).toBeLessThan(rain.speedPxPerSecond / 4);
});
});
+189
View File
@@ -0,0 +1,189 @@
import type { LocalWeather } from './weatherField';
/**
* How the sky looks over the map right now: the wash laid over the scene and what is falling through it.
* Pure maths, no PixiJS — {@link WeatherLayer} is the only thing that knows how to paint it.
*/
export interface SkyState {
/** Degrees above the horizon; negative once the sun has set. */
sunElevationDeg: number;
/** Colour of the wash over the map. */
tint: number;
/** How strongly that wash is applied, 0..1. */
tintAlpha: number;
/** A separate pale layer for fog and driving snow, which lighten rather than darken. */
hazeAlpha: number;
/** How thoroughly the ground is covered, 0..1. Drives the white over roofs and streets. */
snowCover: number;
}
export type PrecipitationKind = 'none' | 'rain' | 'snow';
export interface PrecipitationSpec {
kind: PrecipitationKind;
/** Particles across a 1280×720 viewport; the layer scales this by actual area. */
density: number;
/** Fall angle in degrees away from vertical. Positive drifts to the right of the screen. */
slantDeg: number;
/** Screen pixels per second. */
speedPxPerSecond: number;
}
/** Matches WeatherModel.FullCoverDepthMm: the depth at which the ground reads as fully covered. */
export const FULL_SNOW_COVER_MM = 120;
/** Below this the sky is clear enough that nothing is really falling. */
const PRECIPITATION_FLOOR_MMH = 0.05;
/** Rain below this is sleet or snow. Matches the server's classification threshold. */
const FREEZING_C = 0.5;
const DAYS_PER_YEAR = 365.2425;
/**
* The wash at a given sun elevation, warm through sunset and cold through the night. Ordered from high sun
* to deep night; anything between two stops is interpolated.
*/
const STOPS: readonly { elevation: number; tint: number; alpha: number }[] = [
{ elevation: 12, tint: 0xfff4e0, alpha: 0.0 },
{ elevation: 3, tint: 0xffb877, alpha: 0.16 },
{ elevation: 0, tint: 0xff9152, alpha: 0.26 },
{ elevation: -6, tint: 0x4a4f8c, alpha: 0.46 },
{ elevation: -18, tint: 0x101c38, alpha: 0.66 },
];
/** Day of the year, 1 for 1 January, counting the fraction elapsed so the sun moves smoothly. */
function dayOfYear(date: Date): number {
const startOfYear = new Date(date.getFullYear(), 0, 1);
return (date.getTime() - startOfYear.getTime()) / 86_400_000 + 1;
}
/**
* Solar elevation from the standard declination and hour-angle formulae. Game time is treated as local
* solar time, which is what the in-world calendar already pretends to be.
*/
export function sunElevationDeg(gameTime: Date, latitude: number): number {
const declination = 23.44 * Math.sin((2 * Math.PI * (dayOfYear(gameTime) - 81)) / DAYS_PER_YEAR);
const hours = gameTime.getHours() + gameTime.getMinutes() / 60 + gameTime.getSeconds() / 3600;
const hourAngle = 15 * (hours - 12);
const toRadians = Math.PI / 180;
const phi = latitude * toRadians;
const delta = declination * toRadians;
const angle = hourAngle * toRadians;
const sine = Math.sin(phi) * Math.sin(delta) + Math.cos(phi) * Math.cos(delta) * Math.cos(angle);
return (Math.asin(clamp(sine, -1, 1)) * 180) / Math.PI;
}
/**
* Builds the wash over the map from the sun, the cloud and what is on the ground.
*
* `alreadyDark` is the night theme: the map is drawn dark to begin with, so piling a full night wash on top
* of it would leave the streets unreadable. The wash is pulled back rather than switched off, because dusk
* still has to feel like dusk.
*/
export function skyState(
gameTime: Date,
latitude: number,
weather: LocalWeather,
alreadyDark: boolean,
): SkyState {
const elevation = sunElevationDeg(gameTime, latitude);
const base = interpolateStops(elevation);
// Cloud greys the light down by day and holds a little warmth in at night, so it never simply adds up.
const daylight = clamp((elevation + 6) / 18, 0, 1);
const cloud = clamp01(weather.cloudCover);
const tint = mix(base.tint, 0x8d95a0, cloud * 0.55 * daylight);
const cloudAlpha = cloud * 0.16 * daylight;
const alpha = (base.alpha + cloudAlpha) * (alreadyDark ? 0.45 : 1);
return {
sunElevationDeg: elevation,
tint,
tintAlpha: clamp01(alpha),
hazeAlpha: hazeFor(weather),
snowCover: clamp01(weather.snowDepthMm / FULL_SNOW_COVER_MM),
};
}
/** Fog and heavy snow both wash the scene out; rain barely does. */
function hazeFor(weather: LocalWeather): number {
if (weather.condition === 'fog') return 0.5;
if (weather.condition === 'blizzard') return 0.42;
if (weather.condition === 'sandstorm') return 0.38;
if (weather.condition === 'heavySnow') return 0.24;
return 0;
}
/** What is falling and how hard, ready for the particle layer. */
export function precipitationSpec(weather: LocalWeather): PrecipitationSpec {
if (weather.precipitationMmH < PRECIPITATION_FLOOR_MMH) {
return { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
}
const snowing = weather.temperatureC < FREEZING_C;
// The wind blows towards the reverse of the bearing it comes from; on screen, north is up, so the
// east-west part of that is what tips the fall off vertical.
const towards = (weather.windDirectionDeg + 180) * (Math.PI / 180);
const drift = Math.sin(towards) * weather.windSpeedMs;
const slantDeg = clamp(drift * (snowing ? 1.8 : 3.2), -62, 62);
if (snowing) {
return {
kind: 'snow',
density: Math.min(weather.precipitationMmH * 70, 420),
slantDeg,
speedPxPerSecond: 70 + (weather.windSpeedMs * 9),
};
}
return {
kind: 'rain',
density: Math.min(weather.precipitationMmH * 55, 520),
slantDeg,
speedPxPerSecond: 780 + (weather.precipitationMmH * 55),
};
}
function interpolateStops(elevation: number): { tint: number; alpha: number } {
const first = STOPS[0]!;
if (elevation >= first.elevation) return { tint: first.tint, alpha: first.alpha };
const last = STOPS[STOPS.length - 1]!;
if (elevation <= last.elevation) return { tint: last.tint, alpha: last.alpha };
for (let i = 1; i < STOPS.length; i++) {
const upper = STOPS[i - 1]!;
const lower = STOPS[i]!;
if (elevation > lower.elevation) {
const t = (upper.elevation - elevation) / (upper.elevation - lower.elevation);
return {
tint: mix(upper.tint, lower.tint, t),
alpha: upper.alpha + ((lower.alpha - upper.alpha) * t),
};
}
}
return { tint: last.tint, alpha: last.alpha };
}
function clamp(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
function clamp01(value: number): number {
return clamp(value, 0, 1);
}
function mix(from: number, to: number, t: number): number {
const amount = clamp01(t);
const r = Math.round((((from >> 16) & 0xff) * (1 - amount)) + (((to >> 16) & 0xff) * amount));
const g = Math.round((((from >> 8) & 0xff) * (1 - amount)) + (((to >> 8) & 0xff) * amount));
const b = Math.round(((from & 0xff) * (1 - amount)) + ((to & 0xff) * amount));
return (r << 16) | (g << 8) | b;
}
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import type { Weather, WeatherCondition, WeatherField } from '../api/types';
import { sampleWeatherField } from './weatherField';
function node(overrides: Partial<Weather> = {}): Weather {
return {
condition: 'clear',
temperatureC: 10,
feelsLikeC: 10,
pressureHpa: 1013,
humidity: 0.5,
cloudCover: 0,
precipitationMmH: 0,
windSpeedMs: 0,
windDirectionDeg: 0,
snowDepthMm: 0,
...overrides,
};
}
/** A 2×2 field, row-major from the south-west corner, so index 0 is (west, south). */
function field(nodes: Weather[], size = 2): WeatherField {
return { climate: 'centralEuropean', size, nodes };
}
describe('sampleWeatherField', () => {
it('reads the corners back exactly', () => {
const grid = field([
node({ temperatureC: 0 }), // south-west
node({ temperatureC: 10 }), // south-east
node({ temperatureC: 20 }), // north-west
node({ temperatureC: 30 }), // north-east
]);
expect(sampleWeatherField(grid, 0, 0).temperatureC).toBe(0);
expect(sampleWeatherField(grid, 1, 0).temperatureC).toBe(10);
expect(sampleWeatherField(grid, 0, 1).temperatureC).toBe(20);
expect(sampleWeatherField(grid, 1, 1).temperatureC).toBe(30);
});
it('interpolates between them', () => {
const grid = field([
node({ temperatureC: 0 }),
node({ temperatureC: 10 }),
node({ temperatureC: 20 }),
node({ temperatureC: 30 }),
]);
expect(sampleWeatherField(grid, 0.5, 0).temperatureC).toBeCloseTo(5, 5);
expect(sampleWeatherField(grid, 0, 0.5).temperatureC).toBeCloseTo(10, 5);
expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBeCloseTo(15, 5);
});
it('clamps a sample taken outside the map', () => {
const grid = field([
node({ precipitationMmH: 1 }),
node({ precipitationMmH: 1 }),
node({ precipitationMmH: 5 }),
node({ precipitationMmH: 5 }),
]);
expect(sampleWeatherField(grid, -3, -3).precipitationMmH).toBe(1);
expect(sampleWeatherField(grid, 9, 9).precipitationMmH).toBe(5);
});
it('takes the condition from the nearest node rather than blending it', () => {
const conditions: WeatherCondition[] = ['clear', 'clear', 'clear', 'thunderstorm'];
const grid = field(conditions.map((condition) => node({ condition })));
expect(sampleWeatherField(grid, 0.1, 0.1).condition).toBe('clear');
expect(sampleWeatherField(grid, 0.9, 0.9).condition).toBe('thunderstorm');
// Just past halfway is already the storm's corner; there is no halfway condition to invent.
expect(sampleWeatherField(grid, 0.6, 0.6).condition).toBe('thunderstorm');
});
it('averages bearings the short way round the compass', () => {
const grid = field([
node({ windDirectionDeg: 350 }),
node({ windDirectionDeg: 10 }),
node({ windDirectionDeg: 350 }),
node({ windDirectionDeg: 10 }),
]);
// Averaging 350 and 10 as plain numbers gives 180 — exactly backwards.
const middle = sampleWeatherField(grid, 0.5, 0.5).windDirectionDeg;
expect(Math.min(middle, 360 - middle)).toBeLessThan(1);
});
it('always returns a bearing in range', () => {
const grid = field([
node({ windDirectionDeg: 300 }),
node({ windDirectionDeg: 40 }),
node({ windDirectionDeg: 190 }),
node({ windDirectionDeg: 95 }),
]);
for (let u = 0; u <= 1; u += 0.1) {
for (let v = 0; v <= 1; v += 0.1) {
const bearing = sampleWeatherField(grid, u, v).windDirectionDeg;
expect(bearing).toBeGreaterThanOrEqual(0);
expect(bearing).toBeLessThan(360);
}
}
});
it('falls back to calm weather when the field is malformed', () => {
expect(sampleWeatherField(field([], 0), 0.5, 0.5).condition).toBe('clear');
// A grid that claims to be 8×8 but arrived short must not read off the end of the array.
expect(sampleWeatherField(field([node()], 8), 0.5, 0.5).precipitationMmH).toBe(0);
});
it('handles a one-node field without dividing by zero', () => {
const grid = field([node({ temperatureC: 7 })], 1);
expect(sampleWeatherField(grid, 0.5, 0.5).temperatureC).toBe(7);
});
});
@@ -0,0 +1,107 @@
import type { WeatherCondition, WeatherField } from '../api/types';
/** What the weather is doing at one point of the map, read out of the coarse server grid. */
export interface LocalWeather {
temperatureC: number;
cloudCover: number;
precipitationMmH: number;
windSpeedMs: number;
/** Compass bearing the wind blows from, 0..360. */
windDirectionDeg: number;
snowDepthMm: number;
condition: WeatherCondition;
}
export const CALM: LocalWeather = {
temperatureC: 15,
cloudCover: 0,
precipitationMmH: 0,
windSpeedMs: 0,
windDirectionDeg: 0,
snowDepthMm: 0,
condition: 'clear',
};
/**
* Reads the field at a point, with `u` running west to east and `v` south to north, both 0..1 over the map.
*
* The numbers are interpolated between the four surrounding nodes — the server's pressure systems are smooth
* Gaussians, so a coarse grid loses nothing by being read this way. The condition is a label rather than a
* quantity, so it comes from the nearest node instead: there is no halfway between fog and a thunderstorm.
*/
export function sampleWeatherField(field: WeatherField, u: number, v: number): LocalWeather {
const size = field.size;
if (size < 1 || field.nodes.length < size * size) return CALM;
const x = clamp01(u) * (size - 1);
const y = clamp01(v) * (size - 1);
const x0 = Math.min(Math.floor(x), size - 1);
const y0 = Math.min(Math.floor(y), size - 1);
const x1 = Math.min(x0 + 1, size - 1);
const y1 = Math.min(y0 + 1, size - 1);
const fx = x - x0;
const fy = y - y0;
const at = (column: number, row: number) => field.nodes[(row * size) + column]!;
const topLeft = at(x0, y0);
const topRight = at(x1, y0);
const bottomLeft = at(x0, y1);
const bottomRight = at(x1, y1);
const blend = (pick: (node: (typeof topLeft)) => number): number => {
const top = lerp(pick(topLeft), pick(topRight), fx);
const bottom = lerp(pick(bottomLeft), pick(bottomRight), fx);
return lerp(top, bottom, fy);
};
return {
temperatureC: blend((node) => node.temperatureC),
cloudCover: blend((node) => node.cloudCover),
precipitationMmH: blend((node) => node.precipitationMmH),
windSpeedMs: blend((node) => node.windSpeedMs),
windDirectionDeg: blendBearing(
[topLeft, topRight, bottomLeft, bottomRight].map((node) => node.windDirectionDeg),
fx,
fy,
),
snowDepthMm: blend((node) => node.snowDepthMm),
condition: at(fx < 0.5 ? x0 : x1, fy < 0.5 ? y0 : y1).condition,
};
}
/**
* Bearings wrap, so averaging them as plain numbers puts the midpoint of 350° and 10° at 180° — pointing
* exactly backwards. Interpolating the unit vectors instead gives 0°, which is the answer.
*/
function blendBearing(bearings: number[], fx: number, fy: number): number {
const [topLeft, topRight, bottomLeft, bottomRight] = bearings as [number, number, number, number];
const weights = [
(1 - fx) * (1 - fy),
fx * (1 - fy),
(1 - fx) * fy,
fx * fy,
];
let x = 0;
let y = 0;
for (const [index, bearing] of [topLeft, topRight, bottomLeft, bottomRight].entries()) {
const radians = (bearing * Math.PI) / 180;
x += Math.sin(radians) * weights[index]!;
y += Math.cos(radians) * weights[index]!;
}
if (x === 0 && y === 0) return topLeft;
const degrees = (Math.atan2(x, y) * 180) / Math.PI;
return ((degrees % 360) + 360) % 360;
}
function lerp(from: number, to: number, t: number): number {
return from + ((to - from) * t);
}
function clamp01(value: number): number {
return value < 0 ? 0 : value > 1 ? 1 : value;
}
@@ -0,0 +1,193 @@
import { Container, Graphics } from 'pixi.js';
import type { PrecipitationSpec, SkyState } from './sky';
/** Densities in {@link PrecipitationSpec} are quoted for this viewport and scaled by area from here. */
const REFERENCE_AREA = 1280 * 720;
/** A hard ceiling on particles, whatever the screen size — the whole layer redraws every frame. */
const MAX_PARTICLES = 600;
interface Particle {
x: number;
y: number;
/** 0.6..1.4, so the fall has depth instead of moving as one sheet. */
scale: number;
/** Phase for the sideways sway that makes snow drift rather than fall straight. */
sway: number;
}
/**
* Everything the weather draws over the map: the wash for time of day, cloud, fog and lying snow, plus the
* rain or snow falling through it. Both live in screen space, so panning does not drag the weather along.
*/
export class WeatherLayer {
/** Sits above the map but below the place names, which stay readable through it. */
readonly sky = new Container();
/** Sits above everything — rain falls in front of the labels too. */
readonly precipitation = new Container();
private readonly wash = new Graphics();
private readonly drops = new Graphics();
private readonly particles: Particle[] = [];
private width = 0;
private height = 0;
private state: SkyState = { sunElevationDeg: 90, tint: 0xffffff, tintAlpha: 0, hazeAlpha: 0, snowCover: 0 };
private spec: PrecipitationSpec = { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
private washDirty = true;
constructor() {
this.sky.addChild(this.wash);
this.precipitation.addChild(this.drops);
this.sky.eventMode = 'none';
this.precipitation.eventMode = 'none';
}
resize(width: number, height: number): void {
if (this.width === width && this.height === height) return;
this.width = width;
this.height = height;
this.washDirty = true;
this.resizePool();
}
/** Nothing is drawn until this is called; a world with no weather yet stays untouched. */
apply(state: SkyState, spec: PrecipitationSpec): void {
if (
state.tint !== this.state.tint
|| state.tintAlpha !== this.state.tintAlpha
|| state.hazeAlpha !== this.state.hazeAlpha
|| state.snowCover !== this.state.snowCover
) {
this.washDirty = true;
}
this.state = state;
this.spec = spec;
this.resizePool();
}
/** Steps the falling particles and repaints. Called once per frame. */
advance(deltaMs: number): void {
if (this.washDirty) {
this.paintWash();
this.washDirty = false;
}
this.stepParticles(deltaMs);
this.paintParticles();
}
clear(): void {
this.particles.length = 0;
this.wash.clear();
this.drops.clear();
this.state = { sunElevationDeg: 90, tint: 0xffffff, tintAlpha: 0, hazeAlpha: 0, snowCover: 0 };
this.spec = { kind: 'none', density: 0, slantDeg: 0, speedPxPerSecond: 0 };
}
destroy(): void {
this.sky.destroy({ children: true });
this.precipitation.destroy({ children: true });
}
private paintWash(): void {
this.wash.clear();
if (this.width === 0 || this.height === 0) return;
const { tint, tintAlpha, hazeAlpha, snowCover } = this.state;
if (tintAlpha > 0.001) {
this.wash.rect(0, 0, this.width, this.height).fill({ color: tint, alpha: tintAlpha });
}
// Lying snow goes on before the haze so fog still reads as fog over a white landscape.
if (snowCover > 0.001) {
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xeef3f8, alpha: snowCover * 0.5 });
}
if (hazeAlpha > 0.001) {
this.wash.rect(0, 0, this.width, this.height).fill({ color: 0xd7dce2, alpha: hazeAlpha });
}
}
/** Grows or trims the pool to the density the current weather asks for. */
private resizePool(): void {
const target = this.targetCount();
while (this.particles.length > target) this.particles.pop();
while (this.particles.length < target) this.particles.push(this.spawn(true));
}
private targetCount(): number {
if (this.spec.kind === 'none' || this.width === 0 || this.height === 0) return 0;
const scaled = (this.spec.density * this.width * this.height) / REFERENCE_AREA;
return Math.min(Math.round(scaled), MAX_PARTICLES);
}
/** `anywhere` seeds a new pool across the screen; otherwise the particle re-enters from the top. */
private spawn(anywhere: boolean): Particle {
return {
x: Math.random() * this.width,
y: anywhere ? Math.random() * this.height : -20,
scale: 0.6 + (Math.random() * 0.8),
sway: Math.random() * Math.PI * 2,
};
}
private stepParticles(deltaMs: number): void {
if (this.particles.length === 0) return;
const seconds = deltaMs / 1000;
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
const snowing = this.spec.kind === 'snow';
for (const particle of this.particles) {
const fall = this.spec.speedPxPerSecond * particle.scale * seconds;
particle.y += fall;
particle.x += fall * slant;
if (snowing) {
// Snow wanders as it comes down; rain is too heavy to bother.
particle.sway += seconds * 1.6;
particle.x += Math.sin(particle.sway) * 18 * seconds;
}
if (particle.y > this.height + 20) {
Object.assign(particle, this.spawn(false));
} else if (particle.x < -40) {
particle.x += this.width + 80;
} else if (particle.x > this.width + 40) {
particle.x -= this.width + 80;
}
}
}
private paintParticles(): void {
this.drops.clear();
if (this.particles.length === 0) return;
if (this.spec.kind === 'snow') {
for (const particle of this.particles) {
this.drops.circle(particle.x, particle.y, 1.1 * particle.scale);
}
this.drops.fill({ color: 0xffffff, alpha: 0.85 });
return;
}
// One path for every drop, stroked once: Pixi batches the whole thing into a single draw.
const slant = Math.tan((this.spec.slantDeg * Math.PI) / 180);
const length = 9 + (this.spec.speedPxPerSecond / 90);
for (const particle of this.particles) {
const drop = length * particle.scale;
this.drops.moveTo(particle.x, particle.y).lineTo(particle.x + (drop * slant), particle.y + drop);
}
this.drops.stroke({ width: 1.1, color: 0xaec6dd, alpha: 0.55 });
}
}
+32 -2
View File
@@ -164,6 +164,20 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.sim-controls__weather {
padding-left: 10px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--text-muted);
white-space: nowrap;
border-left: 1px solid var(--panel-border);
}
.sim-controls__weather:empty {
padding-left: 0;
border-left: none;
}
.sim-controls__speeds { .sim-controls__speeds {
display: flex; display: flex;
gap: 2px; gap: 2px;
@@ -324,7 +338,8 @@ body {
.field input[type='text'], .field input[type='text'],
.field input[type='number'], .field input[type='number'],
.field input[type='datetime-local'] { .field input[type='datetime-local'],
.field select {
padding: 7px 9px; padding: 7px 9px;
font: inherit; font: inherit;
font-size: 13px; font-size: 13px;
@@ -334,11 +349,26 @@ body {
border-radius: 7px; border-radius: 7px;
} }
.field input:focus-visible { .field select:disabled {
opacity: 0.6;
}
.field input:focus-visible,
.field select:focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 1px; outline-offset: 1px;
} }
.field__hint {
font-size: 11px;
line-height: 1.4;
color: var(--text-muted);
}
.field__hint:empty {
display: none;
}
.field input[type='range'] { .field input[type='range'] {
accent-color: var(--accent); accent-color: var(--accent);
} }
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import type { ClimateOption } from '../api/types';
import { climateFromLatitude, describeClimate, findClimate } from './climate';
/** Shaped like the real GET /api/climates payload: equator first, unbanded presets mixed in. */
const OPTIONS: ClimateOption[] = [
{ kind: 'equatorial', label: 'Equatorial', koppenCode: 'Af', example: 'Singapore', bandLimit: 10 },
{ kind: 'tropicalMonsoon', label: 'Tropical monsoon', koppenCode: 'Am', example: 'Mumbai' },
{ kind: 'savanna', label: 'Savanna', koppenCode: 'Aw', example: 'Nairobi', bandLimit: 20 },
{ kind: 'hotDesert', label: 'Hot desert', koppenCode: 'BWh', example: 'Cairo', bandLimit: 33 },
{ kind: 'coldSteppe', label: 'Cold steppe', koppenCode: 'BSk', example: 'Astana' },
{ kind: 'mediterranean', label: 'Mediterranean', koppenCode: 'Csa', example: 'Barcelona', bandLimit: 41 },
{ kind: 'humidSubtropical', label: 'Humid subtropical', koppenCode: 'Cfa', example: 'Tokyo', bandLimit: 48 },
{ kind: 'oceanic', label: 'Oceanic', koppenCode: 'Cfb', example: 'London', bandLimit: 54 },
{ kind: 'centralEuropean', label: 'Central European', koppenCode: 'Dfb', example: 'Warsaw', bandLimit: 62 },
{ kind: 'siberian', label: 'Siberian', koppenCode: 'Dfc', example: 'Yakutsk', bandLimit: 70 },
{ kind: 'tundra', label: 'Tundra', koppenCode: 'ET', example: 'Murmansk', bandLimit: 90.1 },
{ kind: 'highland', label: 'Highland', koppenCode: 'H', example: 'La Paz' },
];
describe('climateFromLatitude', () => {
it('reproduces the server bands', () => {
expect(climateFromLatitude(OPTIONS, 1.35)).toBe('equatorial');
expect(climateFromLatitude(OPTIONS, 13.75)).toBe('savanna');
expect(climateFromLatitude(OPTIONS, 30.05)).toBe('hotDesert');
expect(climateFromLatitude(OPTIONS, 37.98)).toBe('mediterranean');
expect(climateFromLatitude(OPTIONS, 51.51)).toBe('oceanic');
expect(climateFromLatitude(OPTIONS, 55.75)).toBe('centralEuropean');
expect(climateFromLatitude(OPTIONS, 62.03)).toBe('siberian');
expect(climateFromLatitude(OPTIONS, 78.22)).toBe('tundra');
});
it('ignores the hemisphere', () => {
expect(climateFromLatitude(OPTIONS, -33.87)).toBe(climateFromLatitude(OPTIONS, 33.87));
expect(climateFromLatitude(OPTIONS, -78.22)).toBe('tundra');
});
it('skips the presets the server never guesses', () => {
const guessed = new Set<string | null>();
for (let degrees = 0; degrees <= 90; degrees += 0.5) {
guessed.add(climateFromLatitude(OPTIONS, degrees));
}
expect(guessed.has('tropicalMonsoon')).toBe(false);
expect(guessed.has('coldSteppe')).toBe(false);
expect(guessed.has('highland')).toBe(false);
});
it('falls back to the last band at the pole', () => {
expect(climateFromLatitude(OPTIONS, 90)).toBe('tundra');
});
it('returns null when it has nothing to work with', () => {
expect(climateFromLatitude(OPTIONS, Number.NaN)).toBeNull();
expect(climateFromLatitude([], 50)).toBeNull();
// A catalogue with no bands at all cannot guess anything.
expect(climateFromLatitude([OPTIONS[11]!], 50)).toBeNull();
});
});
describe('describeClimate', () => {
it('names the preset, its code and a place it feels like', () => {
expect(describeClimate(OPTIONS[8]!)).toBe('Central European (Dfb) · like Warsaw');
});
});
describe('findClimate', () => {
it('looks a preset up by kind', () => {
expect(findClimate(OPTIONS, 'siberian')?.example).toBe('Yakutsk');
expect(findClimate(OPTIONS, null)).toBeNull();
});
});
+37
View File
@@ -0,0 +1,37 @@
import type { ClimateKind, ClimateOption } from '../api/types';
/**
* Reproduces the server's latitude guess from the band limits it sent us, so the create form can preview the
* default without keeping a second copy of the table that would quietly drift out of step.
*
* Options must arrive equator-first, which is the order `GET /api/climates` uses.
*/
export function climateFromLatitude(
options: readonly ClimateOption[],
latitude: number,
): ClimateKind | null {
if (!Number.isFinite(latitude)) return null;
const band = Math.abs(latitude);
const banded = options.filter((option) => option.bandLimit !== undefined && option.bandLimit !== null);
if (banded.length === 0) return null;
for (const option of banded) {
if (band < option.bandLimit!) return option.kind;
}
return banded[banded.length - 1]!.kind;
}
/** `Central European (Dfb) · like Warsaw` — enough to pick from without reading a table of numbers. */
export function describeClimate(option: ClimateOption): string {
return `${option.label} (${option.koppenCode}) · like ${option.example}`;
}
export function findClimate(
options: readonly ClimateOption[],
kind: ClimateKind | null,
): ClimateOption | null {
if (!kind) return null;
return options.find((option) => option.kind === kind) ?? null;
}
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
formatGameTime, formatGameTime,
formatGameTimeRaw, formatGameTimeRaw,
formatWeekday,
GAME_MINUTES_PER_REAL_SECOND, GAME_MINUTES_PER_REAL_SECOND,
interpolateGameTime, interpolateGameTime,
parseGameTime, parseGameTime,
@@ -41,6 +42,22 @@ describe('formatGameTime', () => {
it('formats a raw API string', () => { it('formats a raw API string', () => {
expect(formatGameTimeRaw('2012-04-12T06:00:00')).toBe('12 April 2012 · 06:00'); expect(formatGameTimeRaw('2012-04-12T06:00:00')).toBe('12 April 2012 · 06:00');
}); });
it('prefixes the short weekday on request', () => {
// 12 April 2012 was a Thursday.
const date = new Date(2012, 3, 12, 6, 0, 0);
expect(formatGameTime(date, { weekday: true })).toBe('Thu 12 April 2012 · 06:00');
expect(formatGameTimeRaw('2012-04-12T06:00:00', { weekday: true })).toBe('Thu 12 April 2012 · 06:00');
expect(formatWeekday(date)).toBe('Thursday');
});
it('names every day of the week', () => {
// A full week starting on Sunday 8 April 2012.
const names = Array.from({ length: 7 }, (_, offset) => formatWeekday(new Date(2012, 3, 8 + offset)));
expect(names).toEqual([
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
]);
});
}); });
describe('interpolateGameTime', () => { describe('interpolateGameTime', () => {
+22 -5
View File
@@ -12,6 +12,13 @@ const MONTHS = [
'July', 'August', 'September', 'October', 'November', 'December', 'July', 'August', 'September', 'October', 'November', 'December',
] as const; ] as const;
/** Indexed by `Date.getDay()`, which counts from Sunday. */
const WEEKDAYS = [
'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
] as const;
const SHORT_WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const;
/** /**
* Parses a naive game datetime from the API (`2012-04-12T06:00:00` or with fractional seconds). * Parses a naive game datetime from the API (`2012-04-12T06:00:00` or with fractional seconds).
* Treats the value as a local calendar instant, not UTC. * Treats the value as a local calendar instant, not UTC.
@@ -42,19 +49,29 @@ export function parseGameTime(raw: string): Date | null {
return date; return date;
} }
/** Formats as `12 April 2012 · 06:00` (no seconds — they are meaningless at 5 min/s). */ /**
export function formatGameTime(date: Date): string { * Formats as `12 April 2012 · 06:00` (no seconds — they are meaningless at 5 min/s).
* Pass `weekday` for `Thu 12 April 2012 · 06:00`; the day of the week matters in game but only clutters
* the world list, so it is opt-in rather than always on.
*/
export function formatGameTime(date: Date, options?: { weekday?: boolean }): string {
const day = date.getDate(); const day = date.getDate();
const month = MONTHS[date.getMonth()]!; const month = MONTHS[date.getMonth()]!;
const year = date.getFullYear(); const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0');
return `${day} ${month} ${year} · ${hours}:${minutes}`; const prefix = options?.weekday ? `${SHORT_WEEKDAYS[date.getDay()]!} ` : '';
return `${prefix}${day} ${month} ${year} · ${hours}:${minutes}`;
} }
export function formatGameTimeRaw(raw: string): string { /** Full weekday name, for the tooltip where there is room for it. */
export function formatWeekday(date: Date): string {
return WEEKDAYS[date.getDay()]!;
}
export function formatGameTimeRaw(raw: string, options?: { weekday?: boolean }): string {
const date = parseGameTime(raw); const date = parseGameTime(raw);
return date ? formatGameTime(date) : raw; return date ? formatGameTime(date, options) : raw;
} }
/** /**
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';
import type { Weather } from '../api/types';
import {
conditionIcon,
conditionLabel,
describeWeather,
formatTemperature,
formatWeather,
formatWind,
windCompass,
} from './weather';
function weather(overrides: Partial<Weather> = {}): Weather {
return {
condition: 'cloudy',
temperatureC: 12.4,
feelsLikeC: 12.4,
pressureHpa: 1008.2,
humidity: 0.71,
cloudCover: 0.62,
precipitationMmH: 0,
windSpeedMs: 5.2,
windDirectionDeg: 270,
snowDepthMm: 0,
...overrides,
};
}
describe('formatTemperature', () => {
it('rounds to whole degrees', () => {
expect(formatTemperature(12.4)).toBe('12 °C');
expect(formatTemperature(-7.6)).toBe('-8 °C');
});
it('never renders a negative zero', () => {
// Math.round(-0.4) is -0, which stringifies as "-0" and looks like a bug just below freezing.
expect(formatTemperature(-0.4)).toBe('0 °C');
});
it('copes with a missing reading', () => {
expect(formatTemperature(Number.NaN)).toBe('—');
});
});
describe('windCompass', () => {
it('maps bearings to the sixteen-point compass', () => {
expect(windCompass(0)).toBe('N');
expect(windCompass(90)).toBe('E');
expect(windCompass(180)).toBe('S');
expect(windCompass(270)).toBe('W');
expect(windCompass(45)).toBe('NE');
expect(windCompass(22.5)).toBe('NNE');
});
it('wraps past a full turn and back through zero', () => {
expect(windCompass(360)).toBe('N');
expect(windCompass(359)).toBe('N');
expect(windCompass(-90)).toBe('W');
});
});
describe('conditionLabel and conditionIcon', () => {
it('reads every condition the server can send', () => {
const conditions: Weather['condition'][] = [
'clear', 'fewClouds', 'cloudy', 'overcast', 'fog', 'drizzle', 'rain', 'heavyRain',
'thunderstorm', 'sleet', 'snow', 'heavySnow', 'blizzard', 'sandstorm',
];
for (const condition of conditions) {
expect(conditionLabel(condition)).not.toBe('');
expect(conditionIcon(condition)).not.toBe('');
}
});
});
describe('formatWeather', () => {
it('leaves out the apparent temperature when it matches the real one', () => {
const line = formatWeather(weather());
expect(line).toContain('Cloudy');
expect(line).toContain('12 °C');
expect(line).not.toContain('feels');
});
it('shows the apparent temperature once it diverges', () => {
const line = formatWeather(weather({ temperatureC: -6, feelsLikeC: -14, windSpeedMs: 12 }));
expect(line).toContain('feels -14 °C');
});
it('reports the wind as a compass bearing and a speed', () => {
expect(formatWind(weather({ windDirectionDeg: 270, windSpeedMs: 5.2 }))).toBe('W 5 m/s');
});
});
describe('describeWeather', () => {
it('mentions precipitation only when something is falling', () => {
expect(describeWeather(weather())).not.toContain('Precipitation');
expect(describeWeather(weather({ precipitationMmH: 2.4 }))).toContain('Precipitation 2.4 mm/h');
});
it('renders the fractions as percentages', () => {
const detail = describeWeather(weather({ humidity: 0.71, cloudCover: 0.62 }));
expect(detail).toContain('Humidity 71%');
expect(detail).toContain('Cloud 62%');
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { Weather, WeatherCondition } from '../api/types';
const CONDITION_LABELS: Record<WeatherCondition, string> = {
clear: 'Clear',
fewClouds: 'Few clouds',
cloudy: 'Cloudy',
overcast: 'Overcast',
fog: 'Fog',
drizzle: 'Drizzle',
rain: 'Rain',
heavyRain: 'Heavy rain',
thunderstorm: 'Thunderstorm',
sleet: 'Sleet',
snow: 'Snow',
heavySnow: 'Heavy snow',
blizzard: 'Blizzard',
sandstorm: 'Sandstorm',
};
const CONDITION_ICONS: Record<WeatherCondition, string> = {
clear: '☀',
fewClouds: '🌤',
cloudy: '⛅',
overcast: '☁',
fog: '🌫',
drizzle: '🌦',
rain: '🌧',
heavyRain: '🌧',
thunderstorm: '⛈',
sleet: '🌨',
snow: '❄',
heavySnow: '❄',
blizzard: '🌬',
sandstorm: '🌪',
};
const COMPASS = [
'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW',
] as const;
export function conditionLabel(condition: WeatherCondition): string {
return CONDITION_LABELS[condition] ?? condition;
}
export function conditionIcon(condition: WeatherCondition): string {
return CONDITION_ICONS[condition] ?? '';
}
/** Whole degrees Celsius. Never renders `-0`, which is what plain rounding produces just below freezing. */
export function formatTemperature(celsius: number): string {
if (!Number.isFinite(celsius)) return '—';
const rounded = Math.round(celsius);
return `${Object.is(rounded, -0) ? 0 : rounded} °C`;
}
/** Turns a bearing into the sixteen-point compass name of where the wind comes from. */
export function windCompass(degrees: number): string {
if (!Number.isFinite(degrees)) return '—';
const normalised = ((degrees % 360) + 360) % 360;
return COMPASS[Math.round(normalised / 22.5) % 16]!;
}
export function formatWind(weather: Weather): string {
return `${windCompass(weather.windDirectionDeg)} ${Math.round(weather.windSpeedMs)} m/s`;
}
/**
* The one-line summary for the HUD: icon, condition and temperature, with the apparent temperature only
* when it actually differs from the real one.
*/
export function formatWeather(weather: Weather): string {
const icon = conditionIcon(weather.condition);
const parts = [`${icon} ${conditionLabel(weather.condition)}`, formatTemperature(weather.temperatureC)];
if (Math.abs(weather.feelsLikeC - weather.temperatureC) >= 1.5) {
parts.push(`feels ${formatTemperature(weather.feelsLikeC)}`);
}
parts.push(formatWind(weather));
return parts.join(' · ');
}
/** Tooltip detail, for the numbers that do not earn a place in the HUD line. */
export function describeWeather(weather: Weather): string {
return [
`${conditionLabel(weather.condition)} ${formatTemperature(weather.temperatureC)}`,
`Feels like ${formatTemperature(weather.feelsLikeC)}`,
`Wind ${formatWind(weather)}`,
`Humidity ${Math.round(weather.humidity * 100)}%`,
`Cloud ${Math.round(weather.cloudCover * 100)}%`,
`Pressure ${Math.round(weather.pressureHpa)} hPa`,
weather.precipitationMmH > 0 ? `Precipitation ${weather.precipitationMmH.toFixed(1)} mm/h` : null,
]
.filter((line): line is string => line !== null)
.join('\n');
}
+101
View File
@@ -0,0 +1,101 @@
using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Tests;
public sealed class ClimateTests
{
[Theory]
[InlineData(1.35, ClimateKind.Equatorial)] // Singapore
[InlineData(13.75, ClimateKind.Savanna)] // Bangkok
[InlineData(30.05, ClimateKind.HotDesert)] // Cairo
[InlineData(37.98, ClimateKind.Mediterranean)] // Athens
[InlineData(51.51, ClimateKind.Oceanic)] // London
[InlineData(55.75, ClimateKind.CentralEuropean)] // Moscow
[InlineData(62.03, ClimateKind.Siberian)] // Yakutsk
[InlineData(78.22, ClimateKind.Tundra)] // Svalbard
public void FromLatitude_lands_on_the_expected_band(double latitude, ClimateKind expected)
{
Assert.Equal(expected, ClimateCatalog.FromLatitude(latitude));
}
[Fact]
public void FromLatitude_ignores_the_hemisphere()
{
for (var degrees = 0.0; degrees <= 90.0; degrees += 0.5)
Assert.Equal(ClimateCatalog.FromLatitude(degrees), ClimateCatalog.FromLatitude(-degrees));
}
[Fact]
public void FromLatitude_is_monotonic_from_the_equator_to_the_pole()
{
// The bands must not interleave: walking north should never hand back a warmer preset than the last.
var previous = -1;
for (var degrees = 0.0; degrees <= 90.0; degrees += 0.25)
{
var index = ClimateCatalog.All
.Select(static (preset, i) => (preset.Kind, Index: i))
.First(entry => entry.Kind == ClimateCatalog.FromLatitude(degrees))
.Index;
Assert.True(index >= previous, $"Latitude {degrees} stepped backwards in the catalogue order.");
previous = index;
}
}
[Fact]
public void Every_preset_is_reachable_and_self_consistent()
{
Assert.Equal(12, ClimateCatalog.All.Count);
foreach (var kind in Enum.GetValues<ClimateKind>())
{
var preset = ClimateCatalog.Get(kind);
Assert.Equal(kind, preset.Kind);
Assert.False(string.IsNullOrWhiteSpace(preset.Label));
Assert.False(string.IsNullOrWhiteSpace(preset.KoppenCode));
Assert.False(string.IsNullOrWhiteSpace(preset.Example));
Assert.InRange(preset.Humidity, 0f, 1f);
Assert.InRange(preset.Wetness, 0f, 1f);
Assert.InRange(preset.Storminess, 1, WeatherSystem.MaxSystems);
}
}
[Fact]
public void Presets_the_latitude_rule_cannot_reach_are_reported_as_such()
{
var reachable = Enumerable.Range(0, 91)
.Select(static degrees => ClimateCatalog.FromLatitude(degrees))
.ToHashSet();
foreach (var preset in ClimateCatalog.All)
Assert.Equal(reachable.Contains(preset.Kind), ClimateCatalog.IsInferable(preset.Kind));
}
[Fact]
public void Band_limits_are_ordered_and_reproduce_the_latitude_rule()
{
// The create form re-derives FromLatitude on the client from exactly these limits, walking the list
// in order, so they have to be ascending and they have to agree at every latitude.
var banded = ClimateCatalog.All
.Select(static preset => (preset.Kind, Limit: ClimateCatalog.BandLimit(preset.Kind)))
.Where(static entry => entry.Limit is not null)
.Select(static entry => (entry.Kind, Limit: entry.Limit!.Value))
.ToArray();
Assert.NotEmpty(banded);
Assert.Equal(banded.OrderBy(static entry => entry.Limit).ToArray(), banded);
for (var degrees = 0.0; degrees <= 90.0; degrees += 0.25)
{
var expected = banded.FirstOrDefault(entry => degrees < entry.Limit, banded[^1]).Kind;
Assert.Equal(expected, ClimateCatalog.FromLatitude(degrees));
}
}
[Fact]
public void Get_rejects_an_undefined_climate()
{
Assert.Throws<ArgumentOutOfRangeException>(() => ClimateCatalog.Get((ClimateKind)99));
}
}
@@ -0,0 +1,266 @@
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Tests;
public sealed class WeatherModelTests
{
private const double Warsaw = 52.23;
private const double Sydney = -33.87;
private static readonly DateTime JanuaryNoon = new(2012, 1, 15, 12, 0, 0, DateTimeKind.Unspecified);
private static readonly DateTime JulyNoon = new(2012, 7, 15, 12, 0, 0, DateTimeKind.Unspecified);
[Fact]
public void Seasons_run_the_other_way_below_the_equator()
{
Assert.True(WeatherModel.SeasonPhase(JulyNoon, Warsaw) > 0.8f);
Assert.True(WeatherModel.SeasonPhase(JanuaryNoon, Warsaw) < -0.8f);
Assert.True(WeatherModel.SeasonPhase(JulyNoon, Sydney) < -0.8f);
Assert.True(WeatherModel.SeasonPhase(JanuaryNoon, Sydney) > 0.8f);
}
[Fact]
public void The_day_peaks_in_the_afternoon_and_bottoms_out_before_dawn()
{
var afternoon = new DateTime(2012, 6, 1, 15, 0, 0, DateTimeKind.Unspecified);
var beforeDawn = new DateTime(2012, 6, 1, 3, 0, 0, DateTimeKind.Unspecified);
Assert.Equal(1f, WeatherModel.DiurnalPhase(afternoon), 3);
Assert.Equal(-1f, WeatherModel.DiurnalPhase(beforeDawn), 3);
}
[Fact]
public void Siberia_is_brutal_in_January_and_pleasant_in_July()
{
var climate = ClimateCatalog.Siberian;
var winter = Calm(climate, 62.03, JanuaryNoon);
var summer = Calm(climate, 62.03, JulyNoon);
Assert.InRange(winter.TemperatureC, -45f, -20f);
Assert.InRange(summer.TemperatureC, 8f, 30f);
}
[Fact]
public void The_equator_barely_notices_the_calendar()
{
var climate = ClimateCatalog.Equatorial;
var january = Calm(climate, 1.35, JanuaryNoon);
var july = Calm(climate, 1.35, JulyNoon);
Assert.InRange(MathF.Abs(january.TemperatureC - july.TemperatureC), 0f, 3f);
Assert.InRange(january.TemperatureC, 20f, 36f);
}
[Fact]
public void A_year_of_a_climate_averages_out_to_its_stated_mean()
{
foreach (var climate in ClimateCatalog.All)
{
var total = 0.0;
var samples = 0;
// Every six hours through a year, so both the seasonal and the daily curve are covered evenly.
for (var hours = 0; hours < 365 * 24; hours += 6)
{
var moment = new DateTime(2012, 1, 1, 0, 0, 0, DateTimeKind.Unspecified).AddHours(hours);
total += Calm(climate, Warsaw, moment).TemperatureC;
samples++;
}
var mean = total / samples;
Assert.True(
Math.Abs(mean - climate.MeanTemperatureC) < 1.5,
$"{climate.Label} averaged {mean:F1} °C against a stated mean of {climate.MeanTemperatureC} °C.");
}
}
[Fact]
public void A_deep_low_clouds_over_and_rains_while_a_high_stays_clear()
{
var climate = ClimateCatalog.CentralEuropean;
var low = WeatherModel.Sample(climate, Warsaw, JulyNoon, anomalyHpa: -22f, 0f, 0f);
var high = WeatherModel.Sample(climate, Warsaw, JulyNoon, anomalyHpa: 14f, 0f, 0f);
Assert.True(low.CloudCover > high.CloudCover);
Assert.True(low.PrecipitationMmH > 0f);
Assert.Equal(0f, high.PrecipitationMmH);
Assert.True(low.PressureHpa < high.PressureHpa);
}
[Fact]
public void Precipitation_falls_as_snow_once_it_is_freezing()
{
var winterNight = new DateTime(2012, 1, 15, 2, 0, 0, DateTimeKind.Unspecified);
var sample = WeatherModel.Sample(ClimateCatalog.Siberian, 62.03, winterNight, -25f, 0f, 0f);
Assert.True(sample.TemperatureC < 0f);
Assert.Contains(
sample.Condition,
new[] { WeatherCondition.Snow, WeatherCondition.HeavySnow, WeatherCondition.Blizzard });
}
[Fact]
public void A_dry_climate_under_a_gale_raises_a_sandstorm_rather_than_rain()
{
// A steep gradient with no depth to it: lots of wind, not enough convergence to cloud over.
var sample = WeatherModel.Sample(ClimateCatalog.HotDesert, 30.05, JulyNoon, 2f, 30f, 0f);
Assert.True(sample.WindSpeedMs > 11f);
Assert.Equal(0f, sample.PrecipitationMmH);
Assert.Equal(WeatherCondition.Sandstorm, sample.Condition);
}
[Fact]
public void Wind_runs_along_the_isobars_and_the_hemisphere_decides_which_way()
{
// Pressure rising to the north. The along-isobar component flips between hemispheres, so the wind
// arrives from the east in the north and from the west in the south. (They are not exactly opposite:
// the friction term drags both towards the low regardless of hemisphere.)
var north = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 20f);
var south = WeatherModel.Sample(ClimateCatalog.Oceanic, Sydney, JulyNoon, 0f, 0f, 20f);
Assert.InRange(north.WindDirectionDeg, 0f, 180f);
Assert.InRange(south.WindDirectionDeg, 180f, 360f);
}
[Fact]
public void A_steeper_gradient_means_a_stronger_wind()
{
var calm = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 0f);
var breezy = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 10f);
var gale = WeatherModel.Sample(ClimateCatalog.Oceanic, Warsaw, JulyNoon, 0f, 0f, 40f);
Assert.Equal(ClimateCatalog.Oceanic.WindSpeedMs, calm.WindSpeedMs, 3);
Assert.True(gale.WindSpeedMs > breezy.WindSpeedMs);
Assert.True(breezy.WindSpeedMs > calm.WindSpeedMs);
}
[Fact]
public void Wind_chill_bites_in_the_cold_and_humidity_bites_in_the_heat()
{
var freezing = WeatherModel.Sample(ClimateCatalog.Tundra, 68, JanuaryNoon, -10f, 15f, 0f);
Assert.True(freezing.FeelsLikeC < freezing.TemperatureC);
var muggy = WeatherModel.Sample(ClimateCatalog.Equatorial, 1.35, JulyNoon, -12f, 0f, 0f);
Assert.True(muggy.TemperatureC > 26f);
Assert.True(muggy.FeelsLikeC > muggy.TemperatureC);
}
[Fact]
public void An_overcast_sky_flattens_the_daily_temperature_swing()
{
var climate = ClimateCatalog.ColdSteppe;
var afternoon = new DateTime(2012, 7, 15, 15, 0, 0, DateTimeKind.Unspecified);
var beforeDawn = new DateTime(2012, 7, 15, 3, 0, 0, DateTimeKind.Unspecified);
var clearSwing = Calm(climate, Warsaw, afternoon).TemperatureC - Calm(climate, Warsaw, beforeDawn).TemperatureC;
var cloudyDay = WeatherModel.Sample(climate, Warsaw, afternoon, -20f, 0f, 0f).TemperatureC;
var cloudyNight = WeatherModel.Sample(climate, Warsaw, beforeDawn, -20f, 0f, 0f).TemperatureC;
Assert.True(clearSwing > cloudyDay - cloudyNight);
}
[Fact]
public void The_wet_season_sits_where_the_preset_says_it_does()
{
// The monsoon peaks just after midsummer; the Mediterranean does its raining in winter.
var monsoonSummer = WeatherModel.WetSeasonFactor(ClimateCatalog.TropicalMonsoon, JulyNoon, 19.08);
var monsoonWinter = WeatherModel.WetSeasonFactor(ClimateCatalog.TropicalMonsoon, JanuaryNoon, 19.08);
Assert.True(monsoonSummer > monsoonWinter);
var medSummer = WeatherModel.WetSeasonFactor(ClimateCatalog.Mediterranean, JulyNoon, 41.39);
var medWinter = WeatherModel.WetSeasonFactor(ClimateCatalog.Mediterranean, JanuaryNoon, 41.39);
Assert.True(medWinter > medSummer);
}
[Fact]
public void A_pressure_system_pulls_the_field_towards_itself_and_fades_at_the_edges()
{
PressureSystem[] systems =
[
new(X: 0.5f, Y: 0.5f, VelocityX: 0f, VelocityY: 0f,
IntensityHpa: -20f, Radius: 0.3f, AgeHours: 10f, LifetimeHours: 40f),
];
var centre = WeatherModel.SampleField(systems, 0.5f, 0.5f);
var edge = WeatherModel.SampleField(systems, 1.5f, 0.5f);
Assert.InRange(centre.Anomaly, -21f, -19f);
Assert.InRange(edge.Anomaly, -0.5f, 0f);
// Pressure climbs as you leave the low, so the gradient east of centre points east.
var offCentre = WeatherModel.SampleField(systems, 0.65f, 0.5f);
Assert.True(offCentre.GradientX > 0f);
}
[Fact]
public void Systems_fade_in_and_out_instead_of_popping()
{
var born = new PressureSystem(0.5f, 0.5f, 0f, 0f, -20f, 0.3f, AgeHours: 0f, LifetimeHours: 40f);
var grown = born with { AgeHours = 20f };
var dying = born with { AgeHours = 40f };
Assert.Equal(0f, WeatherModel.Envelope(born), 3);
Assert.Equal(1f, WeatherModel.Envelope(grown), 3);
Assert.Equal(0f, WeatherModel.Envelope(dying), 3);
}
[Fact]
public void Snow_piles_up_below_freezing_and_melts_above_it()
{
var afterAnHour = WeatherModel.UpdateSnowDepth(0f, temperatureC: -4f, precipitationMmH: 2f, 1f);
Assert.Equal(20f, afterAnHour, 1);
// Rain at the same rate leaves nothing lying.
Assert.Equal(0f, WeatherModel.UpdateSnowDepth(0f, 6f, 2f, 1f), 1);
var thawed = WeatherModel.UpdateSnowDepth(afterAnHour, temperatureC: 8f, precipitationMmH: 0f, 2f);
Assert.True(thawed < afterAnHour);
}
[Fact]
public void Snow_depth_never_leaves_its_bounds()
{
Assert.Equal(0f, WeatherModel.UpdateSnowDepth(5f, temperatureC: 30f, 0f, elapsedHours: 100f));
Assert.Equal(
WeatherModel.MaxSnowDepthMm,
WeatherModel.UpdateSnowDepth(0f, temperatureC: -20f, precipitationMmH: 40f, elapsedHours: 100f));
// A zero-length step still normalises a value that arrived out of range from storage.
Assert.Equal(0f, WeatherModel.UpdateSnowDepth(-5f, -10f, 0f, 0f));
}
[Fact]
public void A_world_opened_in_deep_winter_already_has_snow_on_the_ground()
{
var siberianWinter = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Siberian, 62.03, JanuaryNoon);
var siberianSummer = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Siberian, 62.03, JulyNoon);
Assert.True(siberianWinter > WeatherModel.FullCoverDepthMm);
Assert.Equal(0f, siberianSummer);
// Nowhere warm ever starts under snow, whatever the month.
Assert.Equal(0f, WeatherModel.SeasonalSnowDepth(ClimateCatalog.Equatorial, 1.35, JanuaryNoon));
Assert.Equal(0f, WeatherModel.SeasonalSnowDepth(ClimateCatalog.HotDesert, 30.05, JanuaryNoon));
}
[Fact]
public void The_seasonal_snow_line_follows_the_hemisphere()
{
// Same climate, opposite hemispheres: the snow is on the ground in opposite months.
var north = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Tundra, 68, JanuaryNoon);
var south = WeatherModel.SeasonalSnowDepth(ClimateCatalog.Tundra, -68, JanuaryNoon);
Assert.True(north > 0f);
Assert.True(north > south);
}
private static WeatherSample Calm(ClimatePreset climate, double latitude, DateTime moment) =>
WeatherModel.Sample(climate, latitude, moment, anomalyHpa: 0f, gradientX: 0f, gradientY: 0f);
}
@@ -0,0 +1,200 @@
using Arch.Core;
using TheLivingWorld.Core.Ecs;
using TheLivingWorld.Core.Simulation;
namespace TheLivingWorld.Tests;
public sealed class WeatherSystemTests
{
private const double Warsaw = 52.23;
/// <summary>Midsummer, so the seasonal snow depth is zero everywhere and cannot skew a comparison.</summary>
private static readonly DateTime Summer = new(2012, 7, 15, 12, 0, 0, DateTimeKind.Unspecified);
[Fact]
public void Seed_creates_one_system_per_point_of_storminess()
{
var climate = ClimateCatalog.CentralEuropean;
using var world = new EcsWorld();
WeatherSystem.Seed(world.Ecs, climate, Warsaw, seed: 42, Summer);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
Assert.Equal(climate.Storminess, WeatherSystem.CopySystems(world.Ecs, systems));
}
[Fact]
public void Seed_starts_the_pool_mid_life_so_the_sky_is_never_empty()
{
using var world = new EcsWorld();
WeatherSystem.Seed(world.Ecs, ClimateCatalog.Oceanic, Warsaw, seed: 7, Summer);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(world.Ecs, systems);
// At least one system has faded in far enough to actually be felt at the middle of the map.
var anomaly = WeatherModel.SampleField(systems[..count], 0.5f, 0.5f).Anomaly;
Assert.NotEqual(0f, anomaly);
}
[Fact]
public void The_same_seed_produces_the_same_sky()
{
using var first = new EcsWorld();
using var second = new EcsWorld();
WeatherSystem.Seed(first.Ecs, ClimateCatalog.Siberian, 62.03, seed: 12345, Summer);
WeatherSystem.Seed(second.Ecs, ClimateCatalog.Siberian, 62.03, seed: 12345, Summer);
Span<PressureSystem> a = stackalloc PressureSystem[WeatherSystem.MaxSystems];
Span<PressureSystem> b = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var countA = WeatherSystem.CopySystems(first.Ecs, a);
var countB = WeatherSystem.CopySystems(second.Ecs, b);
Assert.Equal(countA, countB);
for (var i = 0; i < countA; i++) Assert.Equal(a[i], b[i]);
}
[Fact]
public void Systems_drift_across_the_map_and_age_as_they_go()
{
using var world = new EcsWorld();
WeatherSystem.Seed(world.Ecs, ClimateCatalog.CentralEuropean, Warsaw, seed: 3, Summer);
Span<PressureSystem> before = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(world.Ecs, before);
var firstBefore = before[0];
WeatherSystem.Execute(world.Ecs, ClimateCatalog.CentralEuropean, Warsaw, elapsedGameHours: 2f);
Span<PressureSystem> after = stackalloc PressureSystem[WeatherSystem.MaxSystems];
WeatherSystem.CopySystems(world.Ecs, after);
Assert.Equal(count, WeatherSystem.CopySystems(world.Ecs, after));
Assert.Equal(firstBefore.AgeHours + 2f, after[0].AgeHours, 3);
Assert.NotEqual(firstBefore.X, after[0].X);
}
[Fact]
public void An_expired_system_is_recycled_rather_than_destroyed()
{
var climate = ClimateCatalog.Oceanic;
using var world = new EcsWorld();
WeatherSystem.Seed(world.Ecs, climate, Warsaw, seed: 99, Summer);
// Well past every possible lifetime, so the whole pool turns over.
WeatherSystem.Execute(world.Ecs, climate, Warsaw, elapsedGameHours: 500f);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(world.Ecs, systems);
Assert.Equal(climate.Storminess, count);
for (var i = 0; i < count; i++)
{
Assert.Equal(0f, systems[i].AgeHours);
Assert.True(systems[i].LifetimeHours > 0f);
}
}
[Fact]
public void Execute_ignores_a_non_positive_step()
{
using var world = new EcsWorld();
WeatherSystem.Seed(world.Ecs, ClimateCatalog.Savanna, 13.75, seed: 5, Summer);
Span<PressureSystem> before = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(world.Ecs, before);
WeatherSystem.Execute(world.Ecs, ClimateCatalog.Savanna, 13.75, elapsedGameHours: 0f);
Span<PressureSystem> after = stackalloc PressureSystem[WeatherSystem.MaxSystems];
WeatherSystem.CopySystems(world.Ecs, after);
for (var i = 0; i < count; i++) Assert.Equal(before[i], after[i]);
}
[Fact]
public void Reseed_rolls_a_new_sky_without_changing_the_pool_size()
{
var climate = ClimateCatalog.Mediterranean;
using var world = new EcsWorld();
WeatherSystem.Seed(world.Ecs, climate, 37.98, seed: 1, Summer);
Span<PressureSystem> before = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(world.Ecs, before);
var firstBefore = before[0];
WeatherSystem.Reseed(world.Ecs, climate, 37.98, Summer);
Span<PressureSystem> after = stackalloc PressureSystem[WeatherSystem.MaxSystems];
Assert.Equal(count, WeatherSystem.CopySystems(world.Ecs, after));
Assert.NotEqual(firstBefore, after[0]);
}
[Fact]
public void Restore_brings_a_persisted_sky_back_verbatim()
{
using var source = new EcsWorld();
WeatherSystem.Seed(source.Ecs, ClimateCatalog.Tundra, 78.22, seed: 64, Summer);
WeatherSystem.Execute(source.Ecs, ClimateCatalog.Tundra, 78.22, elapsedGameHours: 9f);
Span<PressureSystem> saved = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var count = WeatherSystem.CopySystems(source.Ecs, saved);
var rng = WeatherSystem.RngState(source.Ecs);
var snow = WeatherSystem.SnowDepthMm(source.Ecs);
using var restored = new EcsWorld();
WeatherSystem.Restore(
restored.Ecs, ClimateCatalog.Tundra, 78.22, 0, Summer, rng, snow, saved[..count]);
Span<PressureSystem> read = stackalloc PressureSystem[WeatherSystem.MaxSystems];
Assert.Equal(count, WeatherSystem.CopySystems(restored.Ecs, read));
// Compared as a set: the ECS makes no promise about the order a query hands entities back, and the
// field is a sum over all of them, so only membership matters.
Assert.Equal(
saved[..count].ToArray().OrderBy(static system => system.X).ToArray(),
read[..count].ToArray().OrderBy(static system => system.X).ToArray());
Assert.Equal(rng, WeatherSystem.RngState(restored.Ecs));
Assert.Equal(snow, WeatherSystem.SnowDepthMm(restored.Ecs));
}
[Fact]
public void Restore_falls_back_to_a_fresh_seed_when_nothing_was_stored()
{
var climate = ClimateCatalog.HotDesert;
using var world = new EcsWorld();
WeatherSystem.Restore(
world.Ecs, climate, 30.05, fallbackSeed: 8, gameTime: Summer,
rngState: 0, snowDepthMm: 0, systems: []);
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
Assert.Equal(climate.Storminess, WeatherSystem.CopySystems(world.Ecs, systems));
}
[Fact]
public void Tropical_systems_run_west_and_temperate_ones_run_east()
{
using var tropics = new EcsWorld();
using var temperate = new EcsWorld();
WeatherSystem.Seed(tropics.Ecs, ClimateCatalog.Equatorial, latitude: 5, seed: 21, Summer);
WeatherSystem.Seed(temperate.Ecs, ClimateCatalog.Oceanic, latitude: 51.51, seed: 21, Summer);
Span<PressureSystem> trade = stackalloc PressureSystem[WeatherSystem.MaxSystems];
Span<PressureSystem> westerly = stackalloc PressureSystem[WeatherSystem.MaxSystems];
var tradeCount = WeatherSystem.CopySystems(tropics.Ecs, trade);
var westerlyCount = WeatherSystem.CopySystems(temperate.Ecs, westerly);
for (var i = 0; i < tradeCount; i++) Assert.True(trade[i].VelocityX < 0f);
for (var i = 0; i < westerlyCount; i++) Assert.True(westerly[i].VelocityX > 0f);
}
/// <summary>Owns an Arch world so a failing assert cannot leak it out of the static world registry.</summary>
private sealed class EcsWorld : IDisposable
{
public World Ecs { get; } = World.Create();
public void Dispose() => World.Destroy(Ecs);
}
}
@@ -6,6 +6,7 @@ using TheLivingWorld.Api.Simulation;
using TheLivingWorld.Api.Storage; using TheLivingWorld.Api.Storage;
using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Contracts;
using TheLivingWorld.Core.Export; using TheLivingWorld.Core.Export;
using TheLivingWorld.Core.Simulation;
using TheLivingWorld.Osm; using TheLivingWorld.Osm;
using TheLivingWorld.Osm.Import; using TheLivingWorld.Osm.Import;
using TheLivingWorld.Osm.Overpass; using TheLivingWorld.Osm.Overpass;
@@ -68,6 +69,55 @@ public sealed class WorldGenerationServiceTests : IDisposable
Assert.Equal(start, stored.Clock.GameTime); Assert.Equal(start, stored.Clock.GameTime);
} }
[Fact]
public async Task StartAsync_guesses_the_climate_from_the_location_when_none_was_picked()
{
using var service = CreateService(maxConcurrentWorlds: 2);
var summary = await service.StartAsync(new CreateWorldRequest
{
Name = "Yakutsk",
Latitude = 62.0339,
Longitude = 129.7331,
SizeKm = 5,
}, CancellationToken.None);
Assert.Equal(ClimateKind.Siberian, summary.Climate);
Assert.Equal(ClimateKind.Siberian, (await _store.GetSummaryAsync(summary.Id))?.Climate);
}
[Fact]
public async Task StartAsync_keeps_a_climate_that_fights_the_latitude()
{
using var service = CreateService(maxConcurrentWorlds: 2);
// Deliberately absurd: a tropical Yakutsk. An explicit choice always wins over the guess.
var summary = await service.StartAsync(new CreateWorldRequest
{
Name = "Tropical Yakutsk",
Latitude = 62.0339,
Longitude = 129.7331,
SizeKm = 5,
Climate = ClimateKind.Equatorial,
}, CancellationToken.None);
Assert.Equal(ClimateKind.Equatorial, summary.Climate);
}
[Fact]
public async Task StartAsync_rejects_a_climate_that_is_not_in_the_catalogue()
{
using var service = CreateService(maxConcurrentWorlds: 2);
await Assert.ThrowsAsync<ArgumentException>(() => service.StartAsync(new CreateWorldRequest
{
Latitude = 31.8966010,
Longitude = -100.4858591,
SizeKm = 5,
Climate = (ClimateKind)99,
}, CancellationToken.None));
}
private WorldGenerationService CreateService(int maxConcurrentWorlds) private WorldGenerationService CreateService(int maxConcurrentWorlds)
{ {
// The capacity check runs before any Overpass work, so this generator is never invoked by the // The capacity check runs before any Overpass work, so this generator is never invoked by the
@@ -100,13 +100,113 @@ public sealed class WorldSimulationTests
} }
[Fact] [Fact]
public void OverlayForApi_strips_last_ticked_at() public void OverlayForApi_strips_storage_only_state_and_adds_live_weather()
{ {
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
var overlaid = simulation.OverlayForApi(ReadySummary()); var overlaid = simulation.OverlayForApi(ReadySummary());
Assert.NotNull(overlaid.Clock); Assert.NotNull(overlaid.Clock);
Assert.Null(overlaid.LastTickedAt); Assert.Null(overlaid.LastTickedAt);
Assert.Null(overlaid.WeatherState);
Assert.Equal(ClimateKind.CentralEuropean, overlaid.Climate);
Assert.NotNull(overlaid.Weather);
Assert.InRange(overlaid.Weather.Humidity, 0, 1);
Assert.InRange(overlaid.Weather.CloudCover, 0, 1);
Assert.InRange(overlaid.Weather.WindDirectionDeg, 0, 360);
}
[Fact]
public void Climate_defaults_to_the_latitude_when_the_world_never_picked_one()
{
var summary = ReadySummary() with { Latitude = 78.22, Climate = null };
using var simulation = WorldSimulation.Create(summary, catchUp: false);
Assert.Equal(ClimateKind.Tundra, simulation.Climate);
}
[Fact]
public void ApplyTo_persists_the_climate_and_the_drifting_pressure_systems()
{
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
simulation.Tick(TimeSpan.FromSeconds(30));
var stored = simulation.ApplyTo(ReadySummary());
Assert.Equal(ClimateKind.CentralEuropean, stored.Climate);
Assert.NotNull(stored.WeatherState);
Assert.NotEmpty(stored.WeatherState.Systems);
Assert.NotEqual(0ul, stored.WeatherState.RngState);
// Weather itself is derived, so it has no business in the file.
Assert.Null(stored.Weather);
}
[Fact]
public void A_restart_resumes_the_sky_it_had_rather_than_rolling_a_new_one()
{
using var before = WorldSimulation.Create(ReadySummary(), catchUp: false);
before.Tick(TimeSpan.FromSeconds(45));
var persisted = before.ApplyTo(ReadySummary());
var weatherBefore = before.SnapshotWeather();
using var after = WorldSimulation.Create(persisted with { LastTickedAt = null }, catchUp: false);
var weatherAfter = after.SnapshotWeather();
Assert.Equal(weatherBefore.PressureHpa, weatherAfter.PressureHpa, 1);
Assert.Equal(weatherBefore.Condition, weatherAfter.Condition);
}
[Fact]
public void A_long_absence_rolls_a_fresh_sky_instead_of_stepping_through_it()
{
// Six real hours is roughly two and a half game months - the systems that were drifting are long gone.
var summary = ReadySummary() with { LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromHours(6) };
using var simulation = WorldSimulation.Create(summary, catchUp: true);
var stored = simulation.ApplyTo(summary);
Assert.NotNull(stored.WeatherState);
Assert.Equal(ClimateCatalog.CentralEuropean.Storminess, stored.WeatherState.Systems.Count);
// A reseeded pool is caught mid-life over the map, not parked at age zero off the edge.
Assert.Contains(stored.WeatherState.Systems, static system => system.AgeHours > 0f);
}
[Fact]
public void The_weather_field_covers_the_whole_map_and_varies_across_it()
{
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
var field = simulation.SnapshotWeatherField();
Assert.Equal(ClimateKind.CentralEuropean, field.Climate);
Assert.Equal(WorldSimulation.WeatherGridSize, field.Size);
Assert.Equal(field.Size * field.Size, field.Nodes.Count);
// A drifting pressure system means the corners cannot all read the same pressure.
var pressures = field.Nodes.Select(static node => node.PressureHpa).Distinct().Count();
Assert.True(pressures > 1, "The field is uniform - the pressure systems are not being sampled.");
}
[Fact]
public void Neighbouring_field_nodes_stay_close_together()
{
using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false);
var field = simulation.SnapshotWeatherField();
// Gaussian bumps are smooth, so a coarse grid is safe to interpolate between on the client.
for (var row = 0; row < field.Size; row++)
{
for (var column = 1; column < field.Size; column++)
{
var left = field.Nodes[(row * field.Size) + column - 1];
var right = field.Nodes[(row * field.Size) + column];
Assert.True(
Math.Abs(left.TemperatureC - right.TemperatureC) < 6,
$"Nodes {column - 1} and {column} of row {row} jump by more than six degrees.");
}
}
} }
private static WorldSummaryDto ReadySummary() => new() private static WorldSummaryDto ReadySummary() => new()
@@ -119,6 +219,7 @@ public sealed class WorldSimulationTests
Status = WorldStatus.Ready, Status = WorldStatus.Ready,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
Clock = WorldSimulation.DefaultClock(), Clock = WorldSimulation.DefaultClock(),
Climate = ClimateKind.CentralEuropean,
LastTickedAt = DateTimeOffset.UtcNow, LastTickedAt = DateTimeOffset.UtcNow,
}; };
} }