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