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
@@ -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);
}