Refactor world simulation and API to enhance world state management; introduce StoredWorldDto for internal bookkeeping, improve synchronization between simulation and API data, and implement new simulation options for idle and catch-up behavior. Update documentation to reflect changes in data structures and API endpoints.
This commit is contained in:
@@ -12,17 +12,20 @@ namespace TheLivingWorld.Api.Simulation;
|
||||
public sealed class WorldSimulation : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// A gap longer than this is reseeded rather than stepped. The pressure systems that were drifting when
|
||||
/// the host went down are long gone by then, and replaying days of them would cost more than it is worth.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MaxWeatherCatchUp = TimeSpan.FromHours(24);
|
||||
private static readonly TimeSpan MaxWeatherStep = TimeSpan.FromHours(6);
|
||||
|
||||
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;
|
||||
|
||||
@@ -32,6 +35,7 @@ public sealed class WorldSimulation : IDisposable
|
||||
Entity clockEntity,
|
||||
ClimatePreset climate,
|
||||
double latitude,
|
||||
TimeSpan maxCatchUp,
|
||||
DateTimeOffset lastTickedAt)
|
||||
{
|
||||
WorldId = worldId;
|
||||
@@ -39,7 +43,10 @@ public sealed class WorldSimulation : IDisposable
|
||||
_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; }
|
||||
@@ -58,11 +65,16 @@ public sealed class WorldSimulation : IDisposable
|
||||
/// Builds a simulation from persisted summary state. When <paramref name="catchUp"/> is true and the
|
||||
/// clock is not paused, advances for the wall-clock gap since <see cref="WorldSummaryDto.LastTickedAt"/>.
|
||||
/// </summary>
|
||||
public static WorldSimulation Create(WorldSummaryDto summary, bool catchUp = true)
|
||||
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);
|
||||
@@ -74,7 +86,8 @@ public sealed class WorldSimulation : IDisposable
|
||||
RestoreWeather(ecs, summary, climate, gameTime);
|
||||
|
||||
var lastTickedAt = summary.LastTickedAt ?? DateTimeOffset.UtcNow;
|
||||
var simulation = new WorldSimulation(summary.Id, ecs, entity, climate, summary.Latitude, lastTickedAt);
|
||||
var simulation = new WorldSimulation(
|
||||
summary.Id, ecs, entity, climate, summary.Latitude, settings.MaxCatchUp, lastTickedAt);
|
||||
|
||||
if (catchUp && !clock.Paused)
|
||||
{
|
||||
@@ -92,7 +105,7 @@ public sealed class WorldSimulation : IDisposable
|
||||
|
||||
private static void RestoreWeather(
|
||||
World ecs,
|
||||
WorldSummaryDto summary,
|
||||
StoredWorldDto summary,
|
||||
ClimatePreset climate,
|
||||
DateTime gameTime)
|
||||
{
|
||||
@@ -186,15 +199,20 @@ public sealed class WorldSimulation : IDisposable
|
||||
{
|
||||
// Compare raw ticks: this runs at 10 Hz per world, so snapshotting DTOs just to diff would
|
||||
// allocate for nothing.
|
||||
var before = _ecs.Get<GameClock>(_clockEntity).Ticks;
|
||||
ClockSystem.Execute(_ecs, realElapsed);
|
||||
var elapsedGameTicks = _ecs.Get<GameClock>(_clockEntity).Ticks - before;
|
||||
var before = _ecs.Get<GameClock>(_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<GameClock>(_clockEntity).Ticks - before.Ticks;
|
||||
if (elapsedGameTicks <= 0) return false;
|
||||
|
||||
var elapsedGame = TimeSpan.FromTicks(elapsedGameTicks);
|
||||
var gameTime = new DateTime(_ecs.Get<GameClock>(_clockEntity).Ticks, DateTimeKind.Unspecified);
|
||||
|
||||
if (elapsedGame > MaxWeatherCatchUp)
|
||||
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.
|
||||
@@ -238,6 +256,23 @@ public sealed class WorldSimulation : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public void Touch()
|
||||
{
|
||||
lock (_gate) _lastViewedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>True when nobody has asked for this world recently, so it can afford to tick lazily.</summary>
|
||||
public bool IsIdle(DateTimeOffset now, TimeSpan idleAfter)
|
||||
{
|
||||
if (idleAfter <= TimeSpan.Zero) return false;
|
||||
|
||||
lock (_gate) return now - _lastViewedAt > idleAfter;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
@@ -330,7 +365,8 @@ public sealed class WorldSimulation : IDisposable
|
||||
|
||||
return new WeatherDto
|
||||
{
|
||||
SnowDepthMm = Math.Round(WeatherSystem.SnowDepthMm(_ecs), 1),
|
||||
SnowDepthMm = Math.Round(
|
||||
WeatherModel.LocalSnowDepth(WeatherSystem.SnowDepthMm(_ecs), sample.TemperatureC), 1),
|
||||
Condition = sample.Condition,
|
||||
TemperatureC = Math.Round(sample.TemperatureC, 1),
|
||||
FeelsLikeC = Math.Round(sample.FeelsLikeC, 1),
|
||||
@@ -343,7 +379,7 @@ public sealed class WorldSimulation : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
public WorldSummaryDto ApplyTo(WorldSummaryDto summary)
|
||||
public StoredWorldDto ApplyTo(StoredWorldDto summary)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -358,8 +394,11 @@ public sealed class WorldSimulation : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wire-facing snapshot: live clock and weather, none of the storage-only bookkeeping.</summary>
|
||||
public WorldSummaryDto OverlayForApi(WorldSummaryDto summary)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public WorldSummaryDto OverlayForApi(StoredWorldDto summary)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
@@ -368,13 +407,11 @@ public sealed class WorldSimulation : IDisposable
|
||||
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
|
||||
var count = WeatherSystem.CopySystems(_ecs, systems);
|
||||
|
||||
return summary with
|
||||
return summary.ToSummary() with
|
||||
{
|
||||
Clock = SnapshotClockUnlocked(),
|
||||
Climate = _climate.Kind,
|
||||
Weather = SampleUnlocked(systems[..count], 0.5f, 0.5f),
|
||||
LastTickedAt = null,
|
||||
WeatherState = null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user