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:
@@ -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.Core.Contracts;
|
||||
using TheLivingWorld.Core.Export;
|
||||
using TheLivingWorld.Core.Simulation;
|
||||
using TheLivingWorld.Osm;
|
||||
using TheLivingWorld.Osm.Import;
|
||||
using TheLivingWorld.Osm.Overpass;
|
||||
@@ -68,6 +69,55 @@ public sealed class WorldGenerationServiceTests : IDisposable
|
||||
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)
|
||||
{
|
||||
// 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]
|
||||
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);
|
||||
var overlaid = simulation.OverlayForApi(ReadySummary());
|
||||
|
||||
Assert.NotNull(overlaid.Clock);
|
||||
Assert.Null(overlaid.LastTickedAt);
|
||||
Assert.Null(overlaid.WeatherState);
|
||||
|
||||
Assert.Equal(ClimateKind.CentralEuropean, overlaid.Climate);
|
||||
Assert.NotNull(overlaid.Weather);
|
||||
Assert.InRange(overlaid.Weather.Humidity, 0, 1);
|
||||
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()
|
||||
@@ -119,6 +219,7 @@ public sealed class WorldSimulationTests
|
||||
Status = WorldStatus.Ready,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
Clock = WorldSimulation.DefaultClock(),
|
||||
Climate = ClimateKind.CentralEuropean,
|
||||
LastTickedAt = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user