508 lines
18 KiB
C#
508 lines
18 KiB
C#
using System.Threading.Channels;
|
|
using Microsoft.Extensions.Options;
|
|
using SpaceGame.Api.Universe.Scenario;
|
|
|
|
namespace SpaceGame.Api.Universe.Simulation;
|
|
|
|
public sealed class WorldService
|
|
{
|
|
private const int DeltaHistoryLimit = 256;
|
|
|
|
private readonly Lock _sync = new();
|
|
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 = null!;
|
|
private string? _currentScenarioPath;
|
|
private WorldGenerationOptions? _currentWorldGenerationOptions;
|
|
private long _sequence;
|
|
|
|
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()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return _engine.BuildSnapshot(_world, _sequence);
|
|
}
|
|
}
|
|
|
|
public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return (_sequence, _world.GeneratedAtUtc);
|
|
}
|
|
}
|
|
|
|
public (int ConnectedClients, int DeltaHistoryCount) GetConnectionStats()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
return (_subscribers.Count, _history.Count);
|
|
}
|
|
}
|
|
|
|
public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request);
|
|
if (ship is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return GetShipSnapshotUnsafe(ship.Id);
|
|
}
|
|
}
|
|
|
|
public ShipSnapshot? RemoveShipOrder(string shipId, string orderId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId);
|
|
if (ship is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return GetShipSnapshotUnsafe(ship.Id);
|
|
}
|
|
}
|
|
|
|
public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request);
|
|
if (ship is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return GetShipSnapshotUnsafe(ship.Id);
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? GetPlayerFaction()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_world.PlayerFaction is null && _world.Factions.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
_playerFaction.EnsureDomain(_world);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.CreateOrganization(_world, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.DeleteOrganization(_world, organizationId);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpsertDirective(_world, directiveId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.DeleteDirective(_world, directiveId);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpsertPolicy(_world, policyId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpsertAssignment(_world, assetId, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
_playerFaction.UpdateStrategicIntent(_world, request);
|
|
return GetPlayerFactionSnapshotUnsafe();
|
|
}
|
|
}
|
|
|
|
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
|
|
{
|
|
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
|
|
{
|
|
SingleReader = true,
|
|
SingleWriter = false,
|
|
});
|
|
|
|
Guid subscriberId;
|
|
lock (_sync)
|
|
{
|
|
subscriberId = Guid.NewGuid();
|
|
_subscribers.Add(subscriberId, new SubscriptionState(scope, channel));
|
|
|
|
foreach (var delta in _history.Where((candidate) => candidate.Sequence > afterSequence))
|
|
{
|
|
var filtered = FilterDeltaForScope(delta, scope);
|
|
if (HasMeaningfulDelta(filtered))
|
|
{
|
|
channel.Writer.TryWrite(filtered);
|
|
}
|
|
}
|
|
}
|
|
|
|
cancellationToken.Register(() => Unsubscribe(subscriberId));
|
|
return channel.Reader;
|
|
}
|
|
|
|
public void Tick(float deltaSeconds)
|
|
{
|
|
WorldDelta? delta = null;
|
|
lock (_sync)
|
|
{
|
|
delta = _engine.Tick(_world, deltaSeconds, ++_sequence);
|
|
if (!HasMeaningfulDelta(delta))
|
|
{
|
|
return;
|
|
}
|
|
|
|
_history.Enqueue(delta);
|
|
while (_history.Count > DeltaHistoryLimit)
|
|
{
|
|
_history.Dequeue();
|
|
}
|
|
|
|
foreach (var subscriber in _subscribers.Values.ToList())
|
|
{
|
|
var filtered = FilterDeltaForScope(delta, subscriber.Scope);
|
|
if (HasMeaningfulDelta(filtered))
|
|
{
|
|
subscriber.Channel.Writer.TryWrite(filtered);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public WorldSnapshot Reset()
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (_currentScenarioPath is not null)
|
|
{
|
|
ReplaceWorldUnsafe(
|
|
_worldBuilder.BuildFromScenario(_scenarioLoader.Load(_currentScenarioPath)),
|
|
"reset",
|
|
"World reset requested");
|
|
}
|
|
else if (_currentWorldGenerationOptions is not null)
|
|
{
|
|
ReplaceWorldUnsafe(
|
|
_worldBuilder.BuildFromGeneration(_currentWorldGenerationOptions),
|
|
"reset",
|
|
"World reset requested");
|
|
}
|
|
else
|
|
{
|
|
throw new InvalidOperationException("No world source is configured.");
|
|
}
|
|
|
|
return _engine.BuildSnapshot(_world, _sequence);
|
|
}
|
|
}
|
|
|
|
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
|
|
{
|
|
_world = world;
|
|
_sequence += 1;
|
|
_history.Clear();
|
|
|
|
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);
|
|
|
|
_history.Enqueue(worldDelta);
|
|
foreach (var subscriber in _subscribers.Values.ToList())
|
|
{
|
|
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope));
|
|
}
|
|
}
|
|
|
|
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
|
|
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
|
|
|
|
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
|
|
_engine.BuildSnapshot(_world, _sequence).PlayerFaction;
|
|
|
|
private static bool HasMeaningfulDelta(WorldDelta delta) =>
|
|
delta.RequiresSnapshotRefresh
|
|
|| delta.Events.Count > 0
|
|
|| delta.Celestials.Count > 0
|
|
|| delta.Nodes.Count > 0
|
|
|| delta.Stations.Count > 0
|
|
|| delta.Claims.Count > 0
|
|
|| delta.ConstructionSites.Count > 0
|
|
|| delta.MarketOrders.Count > 0
|
|
|| delta.Policies.Count > 0
|
|
|| delta.Ships.Count > 0
|
|
|| delta.Factions.Count > 0
|
|
|| delta.PlayerFaction is not null
|
|
|| delta.Geopolitics is not null;
|
|
|
|
private void Unsubscribe(Guid subscriberId)
|
|
{
|
|
lock (_sync)
|
|
{
|
|
if (!_subscribers.Remove(subscriberId, out var subscription))
|
|
{
|
|
return;
|
|
}
|
|
|
|
subscription.Channel.Writer.TryComplete();
|
|
}
|
|
}
|
|
|
|
private WorldDelta FilterDeltaForScope(WorldDelta delta, ObserverScope scope)
|
|
{
|
|
if (string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return delta with
|
|
{
|
|
Events = delta.Events.Select((evt) => EnrichEventScope(evt)).ToList(),
|
|
Scope = scope,
|
|
};
|
|
}
|
|
|
|
var systemFilter = scope.SystemId;
|
|
if (string.Equals(scope.ScopeKind, "local-celestial", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.CelestialId is not null)
|
|
{
|
|
systemFilter = ResolveCelestialSystemId(scope.CelestialId);
|
|
}
|
|
|
|
return delta with
|
|
{
|
|
Events = delta.Events
|
|
.Select((evt) => EnrichEventScope(evt))
|
|
.Where((evt) => IsEventVisibleToScope(evt, scope, systemFilter))
|
|
.ToList(),
|
|
Celestials = delta.Celestials.Where((celestial) => systemFilter is null || celestial.SystemId == systemFilter).ToList(),
|
|
Nodes = delta.Nodes.Where((node) => systemFilter is null || node.SystemId == systemFilter).ToList(),
|
|
Stations = delta.Stations.Where((station) => systemFilter is null || station.SystemId == systemFilter).ToList(),
|
|
Claims = delta.Claims.Where((claim) => systemFilter is null || claim.SystemId == systemFilter).ToList(),
|
|
ConstructionSites = delta.ConstructionSites.Where((site) => systemFilter is null || site.SystemId == systemFilter).ToList(),
|
|
MarketOrders = delta.MarketOrders.Where((order) => IsOrderVisibleToScope(order, systemFilter)).ToList(),
|
|
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
|
|
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
|
|
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
|
|
PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null,
|
|
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
|
|
Scope = scope,
|
|
};
|
|
}
|
|
|
|
private SimulationEventRecord EnrichEventScope(SimulationEventRecord evt)
|
|
{
|
|
if (!string.Equals(evt.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) || evt.ScopeEntityId is not null)
|
|
{
|
|
return evt;
|
|
}
|
|
|
|
return evt.EntityKind switch
|
|
{
|
|
"ship" => WithEntityScope(evt, "system", _world.Ships.FirstOrDefault((ship) => ship.Id == evt.EntityId)?.SystemId),
|
|
"station" => WithEntityScope(evt, "system", _world.Stations.FirstOrDefault((station) => station.Id == evt.EntityId)?.SystemId),
|
|
"node" => WithEntityScope(evt, "system", _world.Nodes.FirstOrDefault((node) => node.Id == evt.EntityId)?.SystemId),
|
|
"celestial" => WithEntityScope(evt, "system", _world.Celestials.FirstOrDefault((c) => c.Id == evt.EntityId)?.SystemId),
|
|
"claim" => WithEntityScope(evt, "system", _world.Claims.FirstOrDefault((claim) => claim.Id == evt.EntityId)?.SystemId),
|
|
"construction-site" => WithEntityScope(evt, "system", _world.ConstructionSites.FirstOrDefault((site) => site.Id == evt.EntityId)?.SystemId),
|
|
"market-order" => WithEntityScope(evt, "system", ResolveMarketOrderSystemId(evt.EntityId)),
|
|
_ => evt,
|
|
};
|
|
}
|
|
|
|
private static SimulationEventRecord WithEntityScope(SimulationEventRecord evt, string scopeKind, string? scopeEntityId) =>
|
|
evt with
|
|
{
|
|
Family = evt.Kind.Contains("power", StringComparison.Ordinal) ? "power" :
|
|
evt.Kind.Contains("construction", StringComparison.Ordinal) ? "construction" :
|
|
evt.Kind.Contains("population", StringComparison.Ordinal) ? "population" :
|
|
evt.Kind.Contains("claim", StringComparison.Ordinal) ? "claim" :
|
|
"simulation",
|
|
ScopeKind = scopeKind,
|
|
ScopeEntityId = scopeEntityId,
|
|
};
|
|
|
|
private string? ResolveCelestialSystemId(string celestialId) =>
|
|
_world.Celestials.FirstOrDefault((c) => c.Id == celestialId)?.SystemId;
|
|
|
|
private string? ResolveMarketOrderSystemId(string orderId)
|
|
{
|
|
var order = _world.MarketOrders.FirstOrDefault((candidate) => candidate.Id == orderId);
|
|
if (order?.StationId is not null)
|
|
{
|
|
return _world.Stations.FirstOrDefault((station) => station.Id == order.StationId)?.SystemId;
|
|
}
|
|
|
|
if (order?.ConstructionSiteId is not null)
|
|
{
|
|
return _world.ConstructionSites.FirstOrDefault((site) => site.Id == order.ConstructionSiteId)?.SystemId;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private bool IsOrderVisibleToScope(MarketOrderDelta order, string? systemFilter)
|
|
{
|
|
if (systemFilter is null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (order.StationId is not null)
|
|
{
|
|
return _world.Stations.Any((station) => station.Id == order.StationId && station.SystemId == systemFilter);
|
|
}
|
|
|
|
if (order.ConstructionSiteId is not null)
|
|
{
|
|
return _world.ConstructionSites.Any((site) => site.Id == order.ConstructionSiteId && site.SystemId == systemFilter);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static bool IsEventVisibleToScope(SimulationEventRecord evt, ObserverScope scope, string? systemFilter)
|
|
{
|
|
return scope.ScopeKind switch
|
|
{
|
|
"universe" => true,
|
|
"system" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
|
|
"local-celestial" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter,
|
|
_ => true,
|
|
};
|
|
}
|
|
|
|
private sealed record SubscriptionState(ObserverScope Scope, Channel<WorldDelta> Channel);
|
|
}
|