using TheLivingWorld.Api.Simulation; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Simulation; namespace TheLivingWorld.Tests; public sealed class WorldSimulationTests { [Fact] public void Create_starts_at_default_morning_and_ticks_at_x1() { using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); var before = simulation.SnapshotClock(); Assert.Equal(GameTime.DefaultStart, before.GameTime); Assert.Equal(1, before.TimeScale); Assert.False(before.Paused); simulation.Tick(TimeSpan.FromSeconds(2)); var after = simulation.SnapshotClock(); Assert.Equal(GameTime.DefaultStart.AddMinutes(10), after.GameTime); Assert.True(simulation.IsDirty); } [Fact] public void Tick_does_not_advance_while_paused() { var summary = ReadySummary() with { Clock = WorldSimulation.DefaultClock() with { Paused = true }, }; using var simulation = WorldSimulation.Create(summary, catchUp: false); simulation.Tick(TimeSpan.FromSeconds(5)); Assert.Equal(GameTime.DefaultStart, simulation.SnapshotClock().GameTime); Assert.False(simulation.IsDirty); } [Fact] public void Update_bakes_elapsed_time_before_changing_scale() { using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); simulation.Tick(TimeSpan.FromSeconds(1)); var clock = simulation.Update(new UpdateClockRequest { TimeScale = 2 }); Assert.Equal(2, clock.TimeScale); // Tick advanced 5 game minutes; Update may bake a few extra wall-clock milliseconds. Assert.InRange( clock.GameTime, GameTime.DefaultStart.AddMinutes(5), GameTime.DefaultStart.AddMinutes(5).AddSeconds(30)); Assert.True(simulation.IsDirty); } [Fact] public void Update_rejects_invalid_time_scale() { using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); Assert.Throws(() => simulation.Update(new UpdateClockRequest { TimeScale = 5 })); } [Fact] public void Catch_up_advances_from_last_ticked_at() { var lastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromSeconds(3); var summary = ReadySummary() with { LastTickedAt = lastTickedAt }; using var simulation = WorldSimulation.Create(summary, catchUp: true); // ~3 real seconds at x1 → ~15 game minutes (allow a little wall-clock drift). var actual = simulation.SnapshotClock().GameTime; Assert.InRange( actual, GameTime.DefaultStart.AddMinutes(14), GameTime.DefaultStart.AddMinutes(17)); } [Fact] public void Catch_up_skips_a_world_that_was_paused_when_the_host_went_down() { var summary = ReadySummary() with { Clock = WorldSimulation.DefaultClock() with { Paused = true }, LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromHours(6), }; using var simulation = WorldSimulation.Create(summary, catchUp: true); Assert.Equal(GameTime.DefaultStart, simulation.SnapshotClock().GameTime); // Unpausing must not replay the six hours spent paused offline either. var resumed = simulation.Update(new UpdateClockRequest { Paused = false }); Assert.False(resumed.Paused); Assert.InRange(resumed.GameTime, GameTime.DefaultStart, GameTime.DefaultStart.AddMinutes(5)); } [Fact] 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() { Id = "town-aaaaaaaa", Name = "Town", Latitude = 31.9, Longitude = -100.5, SizeMeters = 10_000, Status = WorldStatus.Ready, CreatedAt = DateTimeOffset.UtcNow, Clock = WorldSimulation.DefaultClock(), Climate = ClimateKind.CentralEuropean, LastTickedAt = DateTimeOffset.UtcNow, }; }