413 lines
14 KiB
C#
413 lines
14 KiB
C#
using Arch.Core;
|
|
using TheLivingWorld.Core.Contracts;
|
|
using TheLivingWorld.Core.Ecs;
|
|
using TheLivingWorld.Core.Simulation;
|
|
|
|
namespace TheLivingWorld.Api.Simulation;
|
|
|
|
/// <summary>
|
|
/// Lightweight live runtime for one world: an Arch world holding the <see cref="GameClock"/> entity and the
|
|
/// pressure systems that drive its weather. Map geometry stays on disk; only simulation state lives here.
|
|
/// </summary>
|
|
public sealed class WorldSimulation : IDisposable
|
|
{
|
|
/// <summary>
|
|
/// 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 MaxWeatherStep = TimeSpan.FromHours(6);
|
|
|
|
/// <summary>The pressure field is only ever read here - one point speaks for the whole map.</summary>
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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(
|
|
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<PressureSystem> 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<PressureSystem> 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<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 > 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<PressureSystem> 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;
|
|
}
|
|
}
|
|
|
|
/// <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.
|
|
/// </summary>
|
|
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<GameClock>(_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();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public WeatherDto SnapshotWeather()
|
|
{
|
|
lock (_gate)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
|
|
Span<PressureSystem> systems = stackalloc PressureSystem[WeatherSystem.MaxSystems];
|
|
var count = WeatherSystem.CopySystems(_ecs, systems);
|
|
return SampleUnlocked(systems[..count]);
|
|
}
|
|
}
|
|
|
|
private WeatherDto SampleUnlocked(ReadOnlySpan<PressureSystem> systems)
|
|
{
|
|
var gameTime = new DateTime(_ecs.Get<GameClock>(_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(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
|
|
|
Span<PressureSystem> 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<GameClock>(_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);
|
|
}
|
|
}
|
|
}
|