Refactor world bootstrap and allow empty startup worlds

This commit is contained in:
2026-03-29 13:22:48 -04:00
parent 640e147ea8
commit 0bb72bee35
79 changed files with 173146 additions and 9235 deletions

View File

@@ -1,26 +1,57 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Scenario;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService(
WorldBootstrapper worldBootstrapper,
IOptions<BalanceOptions> balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
public sealed class WorldService
{
private const int DeltaHistoryLimit = 256;
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly WorldBootstrapper _bootstrapper = worldBootstrapper;
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
private readonly SimulationEngine _engine;
private readonly ScenarioLoader _scenarioLoader;
private readonly WorldBuilder _worldBuilder;
private readonly PlayerFactionService _playerFaction = new();
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = worldBootstrapper.Bootstrap();
private SimulationWorld _world = null!;
private string? _currentScenarioPath;
private WorldGenerationOptions? _currentWorldGenerationOptions;
private long _sequence;
private BalanceOptions? _balanceOverride;
public WorldService(
ScenarioLoader scenarioLoader,
WorldBuilder worldBuilder,
IBalanceService balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{
_orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
_scenarioLoader = scenarioLoader;
_worldBuilder = worldBuilder;
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance);
}
public void New(WorldGenerationOptions options)
{
lock (_sync)
{
_currentScenarioPath = null;
_currentWorldGenerationOptions = options;
ReplaceWorldUnsafe(_worldBuilder.BuildFromGeneration(options), "new", "Generated new world");
}
}
public void LoadFromScenario(string scenarioPath)
{
lock (_sync)
{
_currentScenarioPath = scenarioPath;
_currentWorldGenerationOptions = null;
ReplaceWorldUnsafe(_worldBuilder.BuildFromScenario(_scenarioLoader.Load(scenarioPath)), "load-scenario", $"Loaded scenario {scenarioPath}");
}
}
public WorldSnapshot GetSnapshot()
{
@@ -46,35 +77,6 @@ public sealed class WorldService(
}
}
public BalanceOptions GetBalance()
{
lock (_sync)
{
return new BalanceOptions
{
SimulationSpeedMultiplier = balance.Value.SimulationSpeedMultiplier,
YPlane = balance.Value.YPlane,
ArrivalThreshold = balance.Value.ArrivalThreshold,
MiningRate = balance.Value.MiningRate,
MiningCycleSeconds = balance.Value.MiningCycleSeconds,
TransferRate = balance.Value.TransferRate,
DockingDuration = balance.Value.DockingDuration,
UndockingDuration = balance.Value.UndockingDuration,
UndockDistance = balance.Value.UndockDistance,
};
}
}
public BalanceOptions UpdateBalance(BalanceOptions balance)
{
lock (_sync)
{
_balanceOverride = SanitizeBalance(balance);
ApplyBalance(_balanceOverride);
return GetBalance();
}
}
public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request)
{
lock (_sync)
@@ -121,6 +123,11 @@ public sealed class WorldService(
{
lock (_sync)
{
if (_world.PlayerFaction is null && _world.Factions.Count == 0)
{
return null;
}
_playerFaction.EnsureDomain(_world);
return GetPlayerFactionSnapshotUnsafe();
}
@@ -285,74 +292,61 @@ public sealed class WorldService(
{
lock (_sync)
{
_world = _bootstrapper.Bootstrap();
if (_balanceOverride is not null)
if (_currentScenarioPath is not null)
{
ApplyBalance(_balanceOverride);
ReplaceWorldUnsafe(
_worldBuilder.BuildFromScenario(_scenarioLoader.Load(_currentScenarioPath)),
"reset",
"World reset requested");
}
_sequence += 1;
_history.Clear();
var resetDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
DateTimeOffset.UtcNow,
true,
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],
[],
[],
[],
[],
[],
[],
[],
[],
[],
null,
null);
_history.Enqueue(resetDelta);
foreach (var subscriber in _subscribers.Values.ToList())
else if (_currentWorldGenerationOptions is not null)
{
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope));
ReplaceWorldUnsafe(
_worldBuilder.BuildFromGeneration(_currentWorldGenerationOptions),
"reset",
"World reset requested");
}
else
{
throw new InvalidOperationException("No world source is configured.");
}
return _engine.BuildSnapshot(_world, _sequence);
}
}
private void ApplyBalance(BalanceOptions value)
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
{
balance.Value.SimulationSpeedMultiplier = value.SimulationSpeedMultiplier;
balance.Value.YPlane = value.YPlane;
balance.Value.ArrivalThreshold = value.ArrivalThreshold;
balance.Value.MiningRate = value.MiningRate;
balance.Value.MiningCycleSeconds = value.MiningCycleSeconds;
balance.Value.TransferRate = value.TransferRate;
balance.Value.DockingDuration = value.DockingDuration;
balance.Value.UndockingDuration = value.UndockingDuration;
balance.Value.UndockDistance = value.UndockDistance;
}
_world = world;
_sequence += 1;
_history.Clear();
private static BalanceOptions SanitizeBalance(BalanceOptions candidate)
{
static float finiteOr(float value, float fallback) =>
float.IsFinite(value) ? value : fallback;
var eventTime = DateTimeOffset.UtcNow;
var worldDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
eventTime,
true,
[new SimulationEventRecord("world", "world", eventKind, eventMessage, eventTime, "world", "universe", "world")],
[],
[],
[],
[],
[],
[],
[],
[],
[],
null,
null);
return new BalanceOptions
_history.Enqueue(worldDelta);
foreach (var subscriber in _subscribers.Values.ToList())
{
SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)),
YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)),
ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)),
MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)),
MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)),
TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)),
DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)),
UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)),
UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)),
};
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope));
}
}
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>