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_projects_the_stored_world_and_adds_live_weather() { using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); var overlaid = simulation.OverlayForApi(ReadySummary()); Assert.NotNull(overlaid.Clock); 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); } /// /// The projection is one-way on purpose: the storage type carries the bookkeeping, the wire type has no /// field that could hold it, and derived weather never reaches the file. /// [Fact] public void The_stored_shape_and_the_wire_shape_carry_different_things() { var storedFields = typeof(StoredWorldDto).GetProperties().Select(static p => p.Name).ToHashSet(); var wireFields = typeof(WorldSummaryDto).GetProperties().Select(static p => p.Name).ToHashSet(); Assert.Contains("LastTickedAt", storedFields); Assert.Contains("WeatherState", storedFields); Assert.DoesNotContain("LastTickedAt", wireFields); Assert.DoesNotContain("WeatherState", wireFields); Assert.Contains("Weather", wireFields); Assert.DoesNotContain("Weather", storedFields); } [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_week_of_downtime_only_costs_the_world_the_capped_amount_of_game_time() { var summary = ReadySummary() with { LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(7) }; var options = new SimulationOptions { MaxCatchUpGameHours = 24 }; using var simulation = WorldSimulation.Create(summary, catchUp: true, options); // Uncapped this would be about six game years; the world wakes up one day older instead. var advanced = simulation.SnapshotClock().GameTime - GameTime.DefaultStart; Assert.InRange(advanced, TimeSpan.FromHours(24), TimeSpan.FromHours(24.2)); } [Fact] public void A_zero_cap_means_the_world_replays_everything_it_missed() { var summary = ReadySummary() with { LastTickedAt = DateTimeOffset.UtcNow - TimeSpan.FromHours(1) }; var options = new SimulationOptions { MaxCatchUpGameHours = 0 }; using var simulation = WorldSimulation.Create(summary, catchUp: true, options); // One real hour at x1 is 12.5 game days, and nothing trims it. var advanced = simulation.SnapshotClock().GameTime - GameTime.DefaultStart; Assert.InRange(advanced, TimeSpan.FromDays(12), TimeSpan.FromDays(13)); } [Fact] public void A_world_is_watched_when_it_attaches_and_goes_idle_once_nobody_asks() { using var simulation = WorldSimulation.Create(ReadySummary(), catchUp: false); var now = DateTimeOffset.UtcNow; Assert.False(simulation.IsIdle(now, TimeSpan.FromSeconds(20))); Assert.True(simulation.IsIdle(now + TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(20))); simulation.Touch(); Assert.False(simulation.IsIdle(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(20))); // A zero threshold turns the whole idea off: everything stays watched. Assert.False(simulation.IsIdle(now + TimeSpan.FromDays(1), TimeSpan.Zero)); } [Fact] public void Ticking_lazily_lands_on_the_same_game_time_as_ticking_often() { using var lazy = WorldSimulation.Create(ReadySummary(), catchUp: false); using var eager = WorldSimulation.Create(ReadySummary(), catchUp: false); lazy.Tick(TimeSpan.FromSeconds(5)); for (var i = 0; i < 50; i++) eager.Tick(TimeSpan.FromMilliseconds(100)); // The tick rate is a cost decision, not a correctness one: a step is driven by elapsed wall time. Assert.Equal(lazy.SnapshotClock().GameTime, eager.SnapshotClock().GameTime); } [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); } /// /// The weather has to actually move. The pressure systems drift, so a world sampled hours apart must not /// keep reporting the same sky - that was the failure that hid behind a field which never varied. /// [Fact] public void The_weather_changes_as_the_systems_drift_over() { var summary = ReadySummary() with { Climate = ClimateKind.Oceanic, Latitude = 51.51 }; using var simulation = WorldSimulation.Create(summary, catchUp: false); var readings = new List(); for (var step = 0; step < 8; step++) { // Two game hours a step, which is a few real seconds of play at x1. simulation.Tick(TimeSpan.FromSeconds(24)); readings.Add(simulation.SnapshotWeather().PressureHpa); } Assert.True( readings.Max() - readings.Min() > 0.5, $"Pressure barely moved over sixteen game hours: {string.Join(", ", readings)}"); } private static StoredWorldDto 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, }; }