Refactor world bootstrap and allow empty startup worlds
This commit is contained in:
165
apps/backend/Universe/Simulation/BalanceService.cs
Normal file
165
apps/backend/Universe/Simulation/BalanceService.cs
Normal file
@@ -0,0 +1,165 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class BalanceService : IBalanceService
|
||||
{
|
||||
private readonly Lock _sync = new();
|
||||
private BalanceOptions _current;
|
||||
|
||||
public BalanceService(IOptions<BalanceOptions> defaults)
|
||||
{
|
||||
_current = Sanitize(defaults.Value);
|
||||
}
|
||||
|
||||
public float SimulationSpeedMultiplier
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.SimulationSpeedMultiplier;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float YPlane
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.YPlane;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float ArrivalThreshold
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.ArrivalThreshold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float MiningRate
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.MiningRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float MiningCycleSeconds
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.MiningCycleSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float TransferRate
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.TransferRate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float DockingDuration
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.DockingDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float UndockingDuration
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.UndockingDuration;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public float UndockDistance
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _current.UndockDistance;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BalanceOptions GetCurrent()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return Clone(_current);
|
||||
}
|
||||
}
|
||||
|
||||
public BalanceOptions Update(BalanceOptions candidate)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_current = Sanitize(candidate);
|
||||
return Clone(_current);
|
||||
}
|
||||
}
|
||||
|
||||
private static BalanceOptions Clone(BalanceOptions value)
|
||||
{
|
||||
return new BalanceOptions
|
||||
{
|
||||
SimulationSpeedMultiplier = value.SimulationSpeedMultiplier,
|
||||
YPlane = value.YPlane,
|
||||
ArrivalThreshold = value.ArrivalThreshold,
|
||||
MiningRate = value.MiningRate,
|
||||
MiningCycleSeconds = value.MiningCycleSeconds,
|
||||
TransferRate = value.TransferRate,
|
||||
DockingDuration = value.DockingDuration,
|
||||
UndockingDuration = value.UndockingDuration,
|
||||
UndockDistance = value.UndockDistance,
|
||||
};
|
||||
}
|
||||
|
||||
private static BalanceOptions Sanitize(BalanceOptions candidate)
|
||||
{
|
||||
static float finiteOr(float value, float fallback) =>
|
||||
float.IsFinite(value) ? value : fallback;
|
||||
|
||||
return new BalanceOptions
|
||||
{
|
||||
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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
16
apps/backend/Universe/Simulation/IBalanceService.cs
Normal file
16
apps/backend/Universe/Simulation/IBalanceService.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public interface IBalanceService
|
||||
{
|
||||
float SimulationSpeedMultiplier { get; }
|
||||
float YPlane { get; }
|
||||
float ArrivalThreshold { get; }
|
||||
float MiningRate { get; }
|
||||
float MiningCycleSeconds { get; }
|
||||
float TransferRate { get; }
|
||||
float DockingDuration { get; }
|
||||
float UndockingDuration { get; }
|
||||
float UndockDistance { get; }
|
||||
BalanceOptions GetCurrent();
|
||||
BalanceOptions Update(BalanceOptions candidate);
|
||||
}
|
||||
13
apps/backend/Universe/Simulation/ITimeService.cs
Normal file
13
apps/backend/Universe/Simulation/ITimeService.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public interface ITimeService
|
||||
{
|
||||
int TickIntervalMs { get; }
|
||||
TimeSpan TickInterval { get; }
|
||||
float TickDeltaSeconds { get; }
|
||||
double SimulatedSecondsPerRealSecond { get; }
|
||||
DateTimeOffset UtcNow();
|
||||
float ScaleSimulationDelta(float realDeltaSeconds);
|
||||
double ScaleOrbitalDelta(float simulationDeltaSeconds);
|
||||
double CreateInitialOrbitalTimeSeconds(int seed);
|
||||
}
|
||||
23
apps/backend/Universe/Simulation/TimeService.cs
Normal file
23
apps/backend/Universe/Simulation/TimeService.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class TimeService(
|
||||
IBalanceService balance,
|
||||
IOptions<OrbitalSimulationOptions> orbitalSimulation) : ITimeService
|
||||
{
|
||||
public int TickIntervalMs => 200;
|
||||
public TimeSpan TickInterval => TimeSpan.FromMilliseconds(TickIntervalMs);
|
||||
public float TickDeltaSeconds => TickIntervalMs / 1000f;
|
||||
public double SimulatedSecondsPerRealSecond => orbitalSimulation.Value.SimulatedSecondsPerRealSecond;
|
||||
|
||||
public DateTimeOffset UtcNow() => DateTimeOffset.UtcNow;
|
||||
|
||||
public float ScaleSimulationDelta(float realDeltaSeconds) =>
|
||||
realDeltaSeconds * MathF.Max(balance.SimulationSpeedMultiplier, 0.01f);
|
||||
|
||||
public double ScaleOrbitalDelta(float simulationDeltaSeconds) =>
|
||||
simulationDeltaSeconds * SimulatedSecondsPerRealSecond;
|
||||
|
||||
public double CreateInitialOrbitalTimeSeconds(int seed) => seed * 97d;
|
||||
}
|
||||
@@ -2,7 +2,9 @@ namespace SpaceGame.Api.Universe.Simulation;
|
||||
|
||||
public sealed class WorldGenerationOptions
|
||||
{
|
||||
public int Seed { get; init; }
|
||||
public int TargetSystemCount { get; init; }
|
||||
public bool UseKnownSystems { get; init; }
|
||||
public int AiControllerFactionCount { get; init; }
|
||||
public bool GeneratePlayerFaction { get; init; }
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user