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