using Arch.Core; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Ecs; using TheLivingWorld.Core.Simulation; namespace TheLivingWorld.Api.Simulation; /// /// Lightweight live runtime for one world: an Arch world holding the entity and the /// pressure systems that drive its weather. Map geometry stays on disk; only simulation state lives here. /// public sealed class WorldSimulation : IDisposable { /// /// A step longer than this stops being a simulation of the weather and becomes a teleport: the pressure /// systems would cross the map and be recycled several times over inside one jump. Deliberately shorter /// than the clock's own cap, because the two are limited for different reasons. /// private static readonly TimeSpan MaxWeatherStep = TimeSpan.FromHours(6); /// The pressure field is only ever read here - one point speaks for the whole map. private const float MapCentre = 0.5f; private readonly object _gate = new(); private readonly World _ecs; private readonly Entity _clockEntity; private readonly ClimatePreset _climate; private readonly double _latitude; private readonly TimeSpan _maxCatchUp; private DateTimeOffset _lastTickedAt; private DateTimeOffset _lastViewedAt; private bool _dirty; private bool _disposed; private WorldSimulation( string worldId, World ecs, Entity clockEntity, ClimatePreset climate, double latitude, TimeSpan maxCatchUp, DateTimeOffset lastTickedAt) { WorldId = worldId; _ecs = ecs; _clockEntity = clockEntity; _climate = climate; _latitude = latitude; _maxCatchUp = maxCatchUp; _lastTickedAt = lastTickedAt; // A world is watched the moment it attaches; nobody has had a chance to ask for it yet. _lastViewedAt = DateTimeOffset.UtcNow; } public string WorldId { get; } public ClimateKind Climate => _climate.Kind; public bool IsDirty { get { lock (_gate) return _dirty; } } /// /// Builds a simulation from persisted summary state. When is true and the /// clock is not paused, advances for the wall-clock gap since . /// public static WorldSimulation Create( StoredWorldDto summary, bool catchUp = true, SimulationOptions? options = null) { ArgumentNullException.ThrowIfNull(summary); SimulationComponents.EnsureRegistered(); var settings = options ?? new SimulationOptions(); var clock = summary.Clock ?? DefaultClock(); var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale; var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified); var climate = ClimateCatalog.Get(summary.Climate ?? ClimateCatalog.FromLatitude(summary.Latitude)); var ecs = World.Create(); var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused)); RestoreWeather(ecs, summary, climate, gameTime); var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow; var simulation = new WorldSimulation( summary.Id, ecs, entity, climate, summary.Latitude, settings.MaxCatchUp, lastTickedAt); if (catchUp && !clock.Paused) { var gap = DateTimeOffset.UtcNow - lastTickedAt; if (gap > TimeSpan.Zero) simulation.Tick(gap); } else { // Align wall-clock so a later unpause does not replay time spent paused offline. simulation._lastTickedAt = DateTimeOffset.UtcNow; } return simulation; } private static void RestoreWeather( World ecs, StoredWorldDto summary, ClimatePreset climate, DateTime gameTime) { var fallbackSeed = DeterministicRandom.SeedFrom(summary.Id); var stored = summary.WeatherState; if (stored is null || stored.Systems.Count == 0) { WeatherSystem.Seed(ecs, climate, summary.Latitude, fallbackSeed, gameTime); return; } Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems]; var count = Math.Min(stored.Systems.Count, WeatherSystem.MaxSystems); for (var i = 0; i < count; i++) { var dto = stored.Systems[i]; systems[i] = new PressureSystem( dto.X, dto.Y, dto.VelocityX, dto.VelocityY, dto.IntensityHpa, dto.Radius, dto.AgeHours, dto.LifetimeHours); } WeatherSystem.Restore( ecs, climate, summary.Latitude, fallbackSeed, gameTime, stored.RngState, stored.SnowDepthMm, systems[..count]); } private WeatherStateDto SnapshotWeatherStateUnlocked() { Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems]; var count = WeatherSystem.CopySystems(_ecs, systems); var stored = new PressureSystemDto[count]; for (var i = 0; i < count; i++) { var system = systems[i]; stored[i] = new PressureSystemDto { X = system.X, Y = system.Y, VelocityX = system.VelocityX, VelocityY = system.VelocityY, IntensityHpa = system.IntensityHpa, Radius = system.Radius, AgeHours = system.AgeHours, LifetimeHours = system.LifetimeHours, }; } return new WeatherStateDto { RngState = WeatherSystem.RngState(_ecs), SnowDepthMm = WeatherSystem.SnowDepthMm(_ecs), Systems = stored, }; } public static WorldClockDto DefaultClock(DateTime? startGameTime = null) => new() { GameTime = GameTime.ResolveStart(startGameTime), TimeScale = GameTime.MinTimeScale, Paused = false, }; public void Tick(TimeSpan realElapsed) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); if (realElapsed > TimeSpan.Zero && AdvanceUnlocked(realElapsed)) _dirty = true; _lastTickedAt = DateTimeOffset.UtcNow; } } /// /// Runs the clock and then the weather for one slice of wall time. Returns whether game time actually /// moved, which is false whenever the world is paused. /// private bool AdvanceUnlocked(TimeSpan realElapsed) { // Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would // allocate for nothing. var before = _ecs.Get(_clockEntity); // The hours nobody was here for are capped rather than replayed. A normal tick is a tenth of a // second and never comes near the limit; this only bites after the host has been down. var banked = GameTime.LimitCatchUp(realElapsed, before.TimeScale, _maxCatchUp); ClockSystem.Execute(_ecs, banked); var elapsedGameTicks = _ecs.Get(_clockEntity).Ticks - before.Ticks; if (elapsedGameTicks <= 0) return false; var elapsedGame = TimeSpan.FromTicks(elapsedGameTicks); var gameTime = new DateTime(_ecs.Get(_clockEntity).Ticks, DateTimeKind.Unspecified); if (elapsedGame > MaxWeatherStep) { // One giant step is not a simulation: the systems that were drifting would have blown through // and been replaced many times over. Roll a fresh sky for the season we landed in instead. WeatherSystem.Reseed(_ecs, _climate, _latitude, gameTime); return true; } var hours = (float)elapsedGame.TotalHours; WeatherSystem.Execute(_ecs, _climate, _latitude, hours); // Snow lies on the ground, so it has to be integrated as the sky moves rather than derived from the // instant. The middle of the map speaks for all of it; over ten kilometres that is no lie worth care. var overhead = SampleRawUnlocked(gameTime); WeatherSystem.AccumulateSnow(_ecs, overhead.TemperatureC, overhead.PrecipitationMmH, hours); return true; } private WeatherSample SampleRawUnlocked(DateTime gameTime) { Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems]; var count = WeatherSystem.CopySystems(_ecs, systems); var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems[..count], MapCentre, MapCentre); return WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY); } public WorldClockDto SnapshotClock() { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); return SnapshotClockUnlocked(); } } public DateTimeOffset LastTickedAt { get { lock (_gate) return _lastTickedAt; } } /// /// Records that somebody asked for this world. Any player counts - worlds are shared, so one viewer is /// enough to keep it running at full rate for everyone. /// public void Touch() { lock (_gate) _lastViewedAt = DateTimeOffset.UtcNow; } /// True when nobody has asked for this world recently, so it can afford to tick lazily. public bool IsIdle(DateTimeOffset now, TimeSpan idleAfter) { if (idleAfter <= TimeSpan.Zero) return false; lock (_gate) return now - _lastViewedAt > idleAfter; } /// /// Applies pause / time-scale changes. Elapsed time on the previous settings is baked in first so the /// switch is instantaneous from the player's point of view. /// public WorldClockDto Update(UpdateClockRequest request) { ArgumentNullException.ThrowIfNull(request); lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); var now = DateTimeOffset.UtcNow; var gap = now - _lastTickedAt; if (gap > TimeSpan.Zero) AdvanceUnlocked(gap); ref var clock = ref _ecs.Get(_clockEntity); if (request.TimeScale is { } scale) { if (!GameTime.IsValidTimeScale(scale)) throw new ArgumentOutOfRangeException(nameof(request), $"TimeScale must be {GameTime.MinTimeScale}..{GameTime.MaxTimeScale}."); clock.TimeScale = scale; } if (request.Paused is { } paused) clock.Paused = paused; _lastTickedAt = now; _dirty = true; return SnapshotClockUnlocked(); } } /// /// The world's weather. One reading covers the whole map: a generated world is a town, not a continent, /// and a shower does not fall on half of one. /// public WeatherDto SnapshotWeather() { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems]; var count = WeatherSystem.CopySystems(_ecs, systems); return SampleUnlocked(systems[..count]); } } private WeatherDto SampleUnlocked(ReadOnlySpan systems) { var gameTime = new DateTime(_ecs.Get(_clockEntity).Ticks, DateTimeKind.Unspecified); var (anomaly, gradientX, gradientY) = WeatherModel.SampleField(systems, MapCentre, MapCentre); var sample = WeatherModel.Sample(_climate, _latitude, gameTime, anomaly, gradientX, gradientY); return new WeatherDto { SnowDepthMm = Math.Round(WeatherSystem.SnowDepthMm(_ecs), 1), Condition = sample.Condition, TemperatureC = Math.Round(sample.TemperatureC, 1), FeelsLikeC = Math.Round(sample.FeelsLikeC, 1), PressureHpa = Math.Round(sample.PressureHpa, 1), Humidity = Math.Round(sample.Humidity, 3), CloudCover = Math.Round(sample.CloudCover, 3), PrecipitationMmH = Math.Round(sample.PrecipitationMmH, 2), WindSpeedMs = Math.Round(sample.WindSpeedMs, 1), WindDirectionDeg = Math.Round(sample.WindDirectionDeg, 0), }; } public StoredWorldDto ApplyTo(StoredWorldDto summary) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); return summary with { Clock = SnapshotClockUnlocked(), Climate = _climate.Kind, LastTickedAt = _lastTickedAt, WeatherState = SnapshotWeatherStateUnlocked(), }; } } /// /// Wire-facing snapshot: the stored facts projected to the client shape, with the live clock and weather /// laid over them. The storage bookkeeping cannot come along - the type it would go in has no room for it. /// public WorldSummaryDto OverlayForApi(StoredWorldDto summary) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); Span systems = stackalloc PressureSystem[WeatherSystem.MaxSystems]; var count = WeatherSystem.CopySystems(_ecs, systems); return summary.ToSummary() with { Clock = SnapshotClockUnlocked(), Climate = _climate.Kind, Weather = SampleUnlocked(systems[..count]), }; } } public void ClearDirty() { lock (_gate) _dirty = false; } private WorldClockDto SnapshotClockUnlocked() { ref var clock = ref _ecs.Get(_clockEntity); return new WorldClockDto { GameTime = new DateTime(clock.Ticks, DateTimeKind.Unspecified), TimeScale = clock.TimeScale, Paused = clock.Paused, }; } public void Dispose() { lock (_gate) { if (_disposed) return; _disposed = true; World.Destroy(_ecs); } } }