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 a single entity. /// Map geometry stays on disk; only the clock (and later sim state) lives here. /// public sealed class WorldSimulation : IDisposable { private static readonly ClockSystem ClockSystem = new(); private readonly object _gate = new(); private readonly World _ecs; private readonly Entity _clockEntity; private DateTimeOffset _lastTickedAt; private bool _dirty; private bool _disposed; private WorldSimulation(string worldId, World ecs, Entity clockEntity, DateTimeOffset lastTickedAt) { WorldId = worldId; _ecs = ecs; _clockEntity = clockEntity; _lastTickedAt = lastTickedAt; } public string WorldId { get; } 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(WorldSummaryDto summary, bool catchUp = true) { ArgumentNullException.ThrowIfNull(summary); var clock = summary.Clock ?? DefaultClock(); var scale = GameTime.IsValidTimeScale(clock.TimeScale) ? clock.TimeScale : GameTime.MinTimeScale; var gameTime = DateTime.SpecifyKind(clock.GameTime, DateTimeKind.Unspecified); var ecs = World.Create(); var entity = ecs.Create(new GameClock(gameTime.Ticks, scale, clock.Paused)); var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow; var simulation = new WorldSimulation(summary.Id, ecs, entity, 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; } 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) { var before = SnapshotClockUnlocked(); ClockSystem.Execute(_ecs, realElapsed); var after = SnapshotClockUnlocked(); if (after.GameTime != before.GameTime) _dirty = true; } _lastTickedAt = DateTimeOffset.UtcNow; } } public WorldClockDto SnapshotClock() { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); return SnapshotClockUnlocked(); } } public DateTimeOffset LastTickedAt { get { lock (_gate) return _lastTickedAt; } } /// /// 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) ClockSystem.Execute(_ecs, 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(); } } public WorldSummaryDto ApplyTo(WorldSummaryDto summary) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); return summary with { Clock = SnapshotClockUnlocked(), LastTickedAt = _lastTickedAt, }; } } /// Wire-facing snapshot: live clock, no internal last-tick stamp. public WorldSummaryDto OverlayForApi(WorldSummaryDto summary) { lock (_gate) { ObjectDisposedException.ThrowIf(_disposed, this); return summary with { Clock = SnapshotClockUnlocked(), LastTickedAt = null, }; } } 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); } } }