using System.Threading.Channels; using Microsoft.Extensions.Options; using SpaceGame.Api.Universe.Bootstrap; using SpaceGame.Api.Universe.Scenario; using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Universe.Simulation; public sealed class WorldService { private const int DeltaHistoryLimit = 256; private const string StarterPlayerShipId = "ship_arg_s_scout_01_a"; private readonly Lock _sync = new(); private readonly OrbitalSimulationSnapshot _orbitalSimulation; private readonly SimulationEngine _engine; private readonly IPlayerIdentityResolver _playerIdentityResolver; private readonly IPlayerStateStore _playerStateStore; private readonly PlayerFactionProjectionService _playerFactionProjection; private readonly ScenarioLoader _scenarioLoader; private readonly WorldBuilder _worldBuilder; private readonly IStaticDataProvider _staticData; private readonly WorldSeedingService _worldSeedingService; private readonly PlayerFactionService _playerFaction = new(); private readonly Dictionary _subscribers = []; private readonly Queue _history = []; private SimulationWorld _world = null!; private string? _currentScenarioPath; private WorldGenerationOptions? _currentWorldGenerationOptions; private long _sequence; public WorldService( ScenarioLoader scenarioLoader, WorldBuilder worldBuilder, IStaticDataProvider staticData, WorldSeedingService worldSeedingService, IPlayerStateStore playerStateStore, IPlayerIdentityResolver playerIdentityResolver, PlayerFactionProjectionService playerFactionProjection, IBalanceService balance, IOptions orbitalSimulationOptions) { _orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); _playerStateStore = playerStateStore; _playerIdentityResolver = playerIdentityResolver; _playerFactionProjection = playerFactionProjection; _scenarioLoader = scenarioLoader; _worldBuilder = worldBuilder; _staticData = staticData; _worldSeedingService = worldSeedingService; _engine = new SimulationEngine(orbitalSimulationOptions.Value, balance, playerStateStore); } 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) { ValidateShipOrderRequestUnsafe(shipId, request); var ship = CanCurrentActorAccessGm() ? EnqueueGmShipOrderUnsafe(shipId, request) : _playerFaction.EnqueueDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request); if (ship is null) { return null; } return GetShipSnapshotUnsafe(ship.Id); } } public ShipSnapshot? RemoveShipOrder(string shipId, string orderId) { lock (_sync) { var ship = CanCurrentActorAccessGm() ? RemoveGmShipOrderUnsafe(shipId, orderId) : _playerFaction.RemoveDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId); if (ship is null) { return null; } return GetShipSnapshotUnsafe(ship.Id); } } public ShipSnapshot? UpdateShipOrder(string shipId, string orderId, ShipOrderUpdateCommandRequest request) { lock (_sync) { ValidateShipOrderRequestUnsafe(shipId, ToCommandRequest(request)); var ship = CanCurrentActorAccessGm() ? UpdateGmShipOrderUnsafe(shipId, orderId, request) : _playerFaction.UpdateDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request); if (ship is null) { return null; } return GetShipSnapshotUnsafe(ship.Id); } } public ShipSnapshot? ReorderShipOrder(string shipId, string orderId, ShipOrderReorderRequest request) { lock (_sync) { var ship = CanCurrentActorAccessGm() ? ReorderGmShipOrderUnsafe(shipId, orderId, request.TargetIndex) : _playerFaction.ReorderDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId, request.TargetIndex); if (ship is null) { return null; } return GetShipSnapshotUnsafe(ship.Id); } } public ShipSnapshot? UpdateShipDefaultBehavior(string shipId, ShipDefaultBehaviorCommandRequest request) { lock (_sync) { var ship = CanCurrentActorAccessGm() ? ConfigureGmShipBehaviorUnsafe(shipId, request) : _playerFaction.ConfigureDirectShipBehavior(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request); if (ship is null) { return null; } return GetShipSnapshotUnsafe(ship.Id); } } public PlayerFactionSnapshot? GetPlayerFaction() { lock (_sync) { var playerKey = GetCurrentPlayerKey(); var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey) ?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey); return _playerFactionProjection.ToSnapshot(player); } } public PlayerFactionSnapshot? CompletePlayerOnboarding(CompletePlayerOnboardingRequest request) { lock (_sync) { if (!_staticData.RaceDefinitions.TryGetValue(request.RaceId.Trim(), out var race)) { throw new InvalidOperationException($"Race '{request.RaceId}' is not defined in static data."); } var playerKey = GetCurrentPlayerKey(); var player = _playerFaction.CompleteOnboarding(_world, _playerStateStore, playerKey, request); var playerFaction = CreatePlayerOwnedFactionUnsafe(player, race); var starterSystemId = ResolveStarterSystemIdUnsafe(); SpawnPlayerStarterShipUnsafe(playerFaction, starterSystemId); _playerFaction.EnsureInitializedDomain(_world, _playerStateStore, playerKey); PublishSnapshotRefreshUnsafe("player-onboarding", $"Initialized player {player.PersonaName}", "faction", playerFaction.Id); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? CreatePlayerOrganization(PlayerOrganizationCommandRequest request) { lock (_sync) { _playerFaction.CreateOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? DeletePlayerOrganization(string organizationId) { lock (_sync) { _playerFaction.DeleteOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpdatePlayerOrganizationMembership(string organizationId, PlayerOrganizationMembershipCommandRequest request) { lock (_sync) { _playerFaction.UpdateOrganizationMembership(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpsertPlayerDirective(string? directiveId, PlayerDirectiveCommandRequest request) { lock (_sync) { _playerFaction.UpsertDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? DeletePlayerDirective(string directiveId) { lock (_sync) { _playerFaction.DeleteDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpsertPlayerPolicy(string? policyId, PlayerPolicyCommandRequest request) { lock (_sync) { _playerFaction.UpsertPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), policyId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpsertPlayerAutomationPolicy(string? automationPolicyId, PlayerAutomationPolicyCommandRequest request) { lock (_sync) { _playerFaction.UpsertAutomationPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), automationPolicyId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpsertPlayerReinforcementPolicy(string? reinforcementPolicyId, PlayerReinforcementPolicyCommandRequest request) { lock (_sync) { _playerFaction.UpsertReinforcementPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), reinforcementPolicyId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpsertPlayerProductionProgram(string? productionProgramId, PlayerProductionProgramCommandRequest request) { lock (_sync) { _playerFaction.UpsertProductionProgram(_world, _playerStateStore, GetCurrentPlayerKey(), productionProgramId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpsertPlayerAssignment(string assetId, PlayerAssetAssignmentCommandRequest request) { lock (_sync) { _playerFaction.UpsertAssignment(_world, _playerStateStore, GetCurrentPlayerKey(), assetId, request); return GetPlayerFactionSnapshotUnsafe(); } } public PlayerFactionSnapshot? UpdatePlayerStrategicIntent(PlayerStrategicIntentCommandRequest request) { lock (_sync) { _playerFaction.UpdateStrategicIntent(_world, _playerStateStore, GetCurrentPlayerKey(), request); return GetPlayerFactionSnapshotUnsafe(); } } public FactionSnapshot CreateFaction(string factionId) { lock (_sync) { if (_world.Factions.Any(candidate => string.Equals(candidate.Id, factionId, StringComparison.Ordinal))) { throw new InvalidOperationException($"Faction '{factionId}' already exists in the current world."); } var faction = _worldSeedingService.CreateFaction(factionId); _world.Factions.Add(faction); var policy = _worldSeedingService.CreatePolicies([faction]).Single(); _world.Policies.Add(policy); var factionCommander = CreateFactionCommander(faction); _world.Commanders.Add(factionCommander); faction.CommanderIds.Add(factionCommander.Id); new GeopoliticalSimulationService().Update(_world, 0f, []); PublishSnapshotRefreshUnsafe("create-faction", $"Created faction {factionId}", "faction", factionId); return _engine.BuildSnapshot(_world, _sequence).Factions.First(candidate => candidate.Id == factionId); } } public ShipSnapshot SpawnShip(SpawnShipCommandRequest request) { lock (_sync) { var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world."); var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world."); var definition = ResolveShipDefinition(request, faction.Id); var shipId = $"ship-{faction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant(); var spawnPosition = ResolveSpawnPosition(system.Definition.Id); var homeStation = _world.Stations.FirstOrDefault(candidate => string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal) && string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal)); var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation); var spatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Anchors); var localPosition = spatialState.LocalPosition ?? Vector3.Zero; var ship = new ShipRuntime { Id = shipId, SystemId = system.Definition.Id, Definition = definition, FactionId = faction.Id, Position = localPosition, TargetPosition = localPosition, SpatialState = spatialState, DefaultBehavior = defaultBehavior, Skills = ShipBootstrapPolicy.CreateSkills(definition), Health = definition.Hull, }; _world.Ships.Add(ship); EnsureShipCommander(faction, ship); new GeopoliticalSimulationService().Update(_world, 0f, []); PublishSnapshotRefreshUnsafe("spawn-ship", $"Spawned ship {ship.Id}", "ship", ship.Id); return GetShipSnapshotUnsafe(ship.Id) ?? throw new InvalidOperationException($"Ship '{ship.Id}' could not be projected."); } } public StationSnapshot SpawnStation(SpawnStationCommandRequest request) { lock (_sync) { var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world."); var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal)) ?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world."); var objective = StationSimulationService.NormalizeStationObjective(request.Objective); var label = string.IsNullOrWhiteSpace(request.Label) ? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}" : request.Label.Trim(); var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant(); var requestedPosition = ResolveStationSpawnPosition(system.Definition.Id); var anchor = ResolveNearestConstructibleAnchor(system.Definition.Id, requestedPosition) ?? throw new InvalidOperationException($"System '{system.Definition.Id}' does not have a valid constructible anchor for station spawning."); var station = new StationRuntime { Id = stationId, SystemId = system.Definition.Id, AnchorId = anchor.Id, Label = label, Color = faction.Color, Objective = objective, Position = Vector3.Zero, FactionId = faction.Id, PolicySetId = faction.DefaultPolicySetId, Health = 600f, MaxHealth = 600f, }; foreach (var moduleId in BuildStarterStationModules(faction.Id, objective)) { AddStationModule(_world, station, moduleId); } station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station); station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station); _world.Stations.Add(station); anchor.OccupyingStructureId = station.Id; new GeopoliticalSimulationService().Update(_world, 0f, []); PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id); return _engine.BuildSnapshot(_world, _sequence).Stations.First(candidate => candidate.Id == station.Id); } } public ChannelReader Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken) { var channel = Channel.CreateUnbounded(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; _playerStateStore.Clear(); _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); _history.Enqueue(worldDelta); foreach (var subscriber in _subscribers.Values.ToList()) { subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope)); } } private void PublishSnapshotRefreshUnsafe( string eventKind, string eventMessage, string entityKind, string entityId, string scopeKind = "universe", string? scopeEntityId = null) { _sequence += 1; var eventTime = DateTimeOffset.UtcNow; var worldDelta = new WorldDelta( _sequence, _world.TickIntervalMs, _world.OrbitalTimeSeconds, _orbitalSimulation, eventTime, true, [new SimulationEventRecord(entityKind, entityId, eventKind, eventMessage, eventTime, "world", scopeKind, scopeEntityId)], [], [], [], [], [], [], [], [], [], [], null); _history.Enqueue(worldDelta); while (_history.Count > DeltaHistoryLimit) { _history.Dequeue(); } 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() => _playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey())); private FactionRuntime CreatePlayerOwnedFactionUnsafe(PlayerFactionRuntime player, RaceDefinition race) { var playerFaction = new FactionRuntime { Id = player.SovereignFactionId, Label = player.PersonaName ?? player.Label, Color = ResolvePlayerFactionColor(race.Id), Credits = 25000f, }; _world.Factions.Add(playerFaction); var policy = _worldSeedingService.CreatePolicies([playerFaction]).Single(); var templateFaction = _staticData.FactionDefinitions.Values .Where(candidate => string.Equals(candidate.RaceId, race.Id, StringComparison.Ordinal)) .OrderBy(candidate => candidate.Id, StringComparer.Ordinal) .Select(candidate => _world.Factions.FirstOrDefault(worldFaction => string.Equals(worldFaction.Id, candidate.Id, StringComparison.Ordinal))) .FirstOrDefault(candidate => candidate is not null); if (templateFaction?.DefaultPolicySetId is { } racePolicyId && _world.Policies.FirstOrDefault(candidate => candidate.Id == racePolicyId) is { } racePolicy) { policy.TradeAccessPolicy = racePolicy.TradeAccessPolicy; policy.DockingAccessPolicy = racePolicy.DockingAccessPolicy; policy.ConstructionAccessPolicy = racePolicy.ConstructionAccessPolicy; policy.OperationalRangePolicy = racePolicy.OperationalRangePolicy; policy.CombatEngagementPolicy = racePolicy.CombatEngagementPolicy; policy.FleeHullRatio = racePolicy.FleeHullRatio; policy.AvoidHostileSystems = racePolicy.AvoidHostileSystems; foreach (var systemId in racePolicy.BlacklistedSystemIds) { policy.BlacklistedSystemIds.Add(systemId); } } _world.Policies.Add(policy); var factionCommander = CreateFactionCommander(playerFaction); _world.Commanders.Add(factionCommander); playerFaction.CommanderIds.Add(factionCommander.Id); return playerFaction; } private string ResolveStarterSystemIdUnsafe() { return _world.Systems .Select(system => system.Definition.Id) .OrderBy(systemId => systemId, StringComparer.Ordinal) .FirstOrDefault() ?? throw new InvalidOperationException("No systems are available for player onboarding."); } private void SpawnPlayerStarterShipUnsafe(FactionRuntime playerFaction, string systemId) { var request = new SpawnShipCommandRequest( playerFaction.Id, systemId, StarterPlayerShipId, Idle); var system = _world.Systems.First(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal)); var definition = ResolveShipDefinition(request, playerFaction.Id); var shipId = $"ship-{playerFaction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant(); var spawnPosition = ResolveSpawnPosition(system.Definition.Id); var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, null); var spatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Anchors); var localPosition = spatialState.LocalPosition ?? Vector3.Zero; var ship = new ShipRuntime { Id = shipId, SystemId = system.Definition.Id, Definition = definition, FactionId = playerFaction.Id, Position = localPosition, TargetPosition = localPosition, SpatialState = spatialState, DefaultBehavior = defaultBehavior, Skills = ShipBootstrapPolicy.CreateSkills(definition), Health = definition.Hull, }; _world.Ships.Add(ship); EnsureShipCommander(playerFaction, ship); new GeopoliticalSimulationService().Update(_world, 0f, []); } private string ResolvePlayerFactionColor(string raceId) => raceId switch { "argon" => "#3b82f6", "boron" => "#14b8a6", "paranid" => "#eab308", "split" => "#b91c1c", "teladi" => "#22c55e", "terran" => "#38bdf8", "xenon" => "#9ca3af", _ => "#94a3b8", }; private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredEffectivePlayerId().ToString("N"); private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm(); private string GetCurrentActorSourceId() => _playerIdentityResolver.GetCurrentPlayerId()?.ToString("N") ?? "gm"; private void ValidateShipOrderRequestUnsafe(string shipId, ShipOrderCommandRequest request) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId) ?? throw new InvalidOperationException($"Ship '{shipId}' was not found."); if (!string.Equals(request.Kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal)) { return; } if (!IsMiningShip(ship.Definition)) { throw new InvalidOperationException($"{ship.Definition.Name} cannot accept Mine Resource because it does not have mining capability."); } if (string.IsNullOrWhiteSpace(request.ItemId)) { throw new InvalidOperationException("Mine Resource requires a ware."); } if (!_world.ItemDefinitions.TryGetValue(request.ItemId, out var itemDefinition)) { throw new InvalidOperationException($"Mine Resource references unknown ware '{request.ItemId}'."); } if (itemDefinition.CargoKind is null) { throw new InvalidOperationException($"Mine Resource ware '{request.ItemId}' is not mineable."); } if (!ship.Definition.SupportsCargoKind(itemDefinition.CargoKind.Value)) { throw new InvalidOperationException($"{ship.Definition.Name} cannot mine '{request.ItemId}' because it cannot store '{itemDefinition.CargoKind.Value.ToDataValue()}'."); } } private static void ApplyShipOrderRequest(ShipOrderRuntime order, ShipOrderUpdateCommandRequest request) { order.Priority = request.Priority; order.InterruptCurrentPlan = request.InterruptCurrentPlan; order.Label = request.Label; order.TargetEntityId = request.TargetEntityId; order.TargetSystemId = request.TargetSystemId; order.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); order.SourceStationId = request.SourceStationId; order.DestinationStationId = request.DestinationStationId; order.ItemId = request.ItemId; order.AnchorId = request.AnchorId; order.ConstructionSiteId = request.ConstructionSiteId; order.ModuleId = request.ModuleId; order.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f); order.Radius = MathF.Max(0f, request.Radius ?? 0f); order.MaxSystemRange = request.MaxSystemRange; order.KnownStationsOnly = request.KnownStationsOnly ?? false; order.Status = OrderStatus.Queued; order.FailureReason = null; } private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); if (ship is null) { return null; } ship.OrderQueue.EnqueuePlayerOrder(new ShipOrderRuntime { Id = $"order-{ship.Id}-{Guid.NewGuid():N}", Kind = request.Kind, SourceKind = ShipOrderSourceKind.Player, SourceId = GetCurrentActorSourceId(), Priority = request.Priority, InterruptCurrentPlan = request.InterruptCurrentPlan, Label = request.Label, TargetEntityId = request.TargetEntityId, TargetSystemId = request.TargetSystemId, TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z), SourceStationId = request.SourceStationId, DestinationStationId = request.DestinationStationId, ItemId = request.ItemId, AnchorId = request.AnchorId, ConstructionSiteId = request.ConstructionSiteId, ModuleId = request.ModuleId, WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f), Radius = MathF.Max(0f, request.Radius ?? 0f), MaxSystemRange = request.MaxSystemRange, KnownStationsOnly = request.KnownStationsOnly ?? false, }); ship.ControlSourceKind = "gm-order"; ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; ship.ControlReason = request.Label ?? request.Kind; ship.NeedsReplan = true; ship.LastReplanReason = "gm-order-enqueued"; ship.LastDeltaSignature = string.Empty; return ship; } private ShipRuntime? RemoveGmShipOrderUnsafe(string shipId, string orderId) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); if (ship is null) { return null; } ship.OrderQueue.RemoveById(orderId); ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) ? "gm-order" : "gm-manual"; ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) ?? "manual-gm-control"; ship.NeedsReplan = true; ship.LastReplanReason = "gm-order-removed"; ship.LastDeltaSignature = string.Empty; return ship; } private ShipRuntime? UpdateGmShipOrderUnsafe(string shipId, string orderId, ShipOrderUpdateCommandRequest request) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); if (ship is null) { return null; } var order = ship.OrderQueue.FindById(orderId); if (order is null || order.SourceKind != ShipOrderSourceKind.Player) { return null; } ApplyShipOrderRequest(order, request); ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) ? "gm-order" : "gm-manual"; ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) ?? request.Label ?? request.Kind; ship.NeedsReplan = true; ship.LastReplanReason = "gm-order-updated"; ship.LastDeltaSignature = string.Empty; return ship; } private ShipRuntime? ReorderGmShipOrderUnsafe(string shipId, string orderId, int targetIndex) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); if (ship is null) { return null; } if (!ship.OrderQueue.TryMovePlayerOrder(orderId, targetIndex)) { return ship; } ship.ControlSourceKind = ship.OrderQueue.HasOrdersFromSource(ShipOrderSourceKind.Player) ? "gm-order" : "gm-manual"; ship.ControlSourceId = ship.OrderQueue.FindLeadingOrderForSource(ShipOrderSourceKind.Player)?.Id; ship.ControlReason = ship.OrderQueue.GetLeadingOrderLabelForSource(ShipOrderSourceKind.Player) ?? "manual-gm-control"; ship.NeedsReplan = true; ship.LastReplanReason = "gm-order-reordered"; ship.LastDeltaSignature = string.Empty; return ship; } private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request) { var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId); if (ship is null) { return null; } ship.DefaultBehavior.Kind = request.Kind; ship.DefaultBehavior.HomeSystemId = request.HomeSystemId ?? ship.SystemId; ship.DefaultBehavior.HomeStationId = request.HomeStationId; ship.DefaultBehavior.AreaSystemId = request.AreaSystemId; ship.DefaultBehavior.TargetEntityId = request.TargetEntityId; ship.DefaultBehavior.ItemId = request.ItemId; ship.DefaultBehavior.PreferredAnchorId = request.PreferredAnchorId; ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId; ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId; ship.DefaultBehavior.TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z); ship.DefaultBehavior.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds); ship.DefaultBehavior.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius); ship.DefaultBehavior.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange); ship.DefaultBehavior.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly; ship.DefaultBehavior.PatrolPoints = (request.PatrolPoints ?? []) .Select(point => new Vector3(point.X, point.Y, point.Z)) .ToList(); ship.DefaultBehavior.PatrolIndex = 0; ship.DefaultBehavior.RepeatOrders = (request.RepeatOrders ?? []) .Select(template => new ShipOrderTemplateRuntime { Kind = template.Kind, Label = template.Label, TargetEntityId = template.TargetEntityId, TargetSystemId = template.TargetSystemId, TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z), SourceStationId = template.SourceStationId, DestinationStationId = template.DestinationStationId, ItemId = template.ItemId, AnchorId = template.AnchorId, ConstructionSiteId = template.ConstructionSiteId, ModuleId = template.ModuleId, WaitSeconds = template.WaitSeconds ?? 0f, Radius = template.Radius ?? 0f, MaxSystemRange = template.MaxSystemRange, KnownStationsOnly = template.KnownStationsOnly ?? false, }) .ToList(); ship.DefaultBehavior.RepeatIndex = 0; ship.ControlSourceKind = "gm-manual"; ship.ControlSourceId = GetCurrentActorSourceId(); ship.ControlReason = request.Kind; ship.NeedsReplan = true; ship.LastReplanReason = "gm-behavior-updated"; ship.LastDeltaSignature = string.Empty; return ship; } private static ShipOrderCommandRequest ToCommandRequest(ShipOrderUpdateCommandRequest request) => new( request.Kind, request.Priority, request.InterruptCurrentPlan, request.Label, request.TargetEntityId, request.TargetSystemId, request.TargetPosition, request.SourceStationId, request.DestinationStationId, request.ItemId, request.AnchorId, request.ConstructionSiteId, request.ModuleId, request.WaitSeconds, request.Radius, request.MaxSystemRange, request.KnownStationsOnly); private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new() { Id = $"commander-faction-{faction.Id}", Kind = CommanderKind.Faction, FactionId = faction.Id, ControlledEntityId = faction.Id, PolicySetId = faction.DefaultPolicySetId, Doctrine = "strategic-control", }; private void EnsureShipCommander(FactionRuntime faction, ShipRuntime ship) { var factionCommander = _world.Commanders.FirstOrDefault(candidate => string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal) && string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal)); if (factionCommander is null) { return; } var commander = new CommanderRuntime { Id = $"commander-ship-{ship.Id}", Kind = CommanderKind.Ship, FactionId = faction.Id, ParentCommanderId = factionCommander.Id, ControlledEntityId = ship.Id, PolicySetId = factionCommander.PolicySetId, Doctrine = "ship-control", Skills = new CommanderSkillProfileRuntime { Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5), Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5), Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5), }, }; ship.CommanderId = commander.Id; ship.PolicySetId = factionCommander.PolicySetId; factionCommander.SubordinateCommanderIds.Add(commander.Id); faction.CommanderIds.Add(commander.Id); _world.Commanders.Add(commander); } private ShipDefinition ResolveShipDefinition(SpawnShipCommandRequest request, string factionId) { if (!string.IsNullOrWhiteSpace(request.ShipId)) { return _staticData.ShipDefinitions.TryGetValue(request.ShipId, out var explicitDefinition) ? explicitDefinition : throw new InvalidOperationException($"Ship '{request.ShipId}' is not defined in static data."); } return _staticData.ShipDefinitions.Values .Where(IsMiningShip) .OrderBy(definition => !definition.Owners.Contains(factionId, StringComparer.Ordinal)) .ThenBy(definition => !definition.SupportsCargoKind(StorageKind.Solid)) .ThenBy(definition => definition.Size != "small") .ThenBy(definition => definition.Id, StringComparer.Ordinal) .FirstOrDefault() ?? throw new InvalidOperationException("No mining ship definition is available in static data."); } private Vector3 ResolveSpawnPosition(string systemId) { var shipsInSystem = _world.Ships.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)); var angle = shipsInSystem * 0.73f; return new Vector3(60f + (shipsInSystem * 12f), 0f, MathF.Sin(angle) * 34f); } private Vector3 ResolveStationSpawnPosition(string systemId) { var stationsInSystem = _world.Stations.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)); var angle = stationsInSystem * 0.91f; var radius = 160f + (stationsInSystem * 42f); return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius); } private AnchorRuntime? ResolveNearestConstructibleAnchor(string systemId, Vector3 position) { var systemPosition = SimulationUnits.MetersToKilometers(position); return _world.Anchors .Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)) .Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind)) .OrderBy(candidate => candidate.Position.DistanceTo(systemPosition)) .FirstOrDefault(); } private string? ResolveNearestAnchorId(string systemId, Vector3 position) => ResolveNearestConstructibleAnchor(systemId, position)?.Id; private IReadOnlyList BuildStarterStationModules(string factionId, string objective) { var modules = new List(); EnsureStationModule(modules, StarterStationLayoutResolver.ResolveDockModuleId(factionId, _staticData.ModuleDefinitions)); var powerModuleId = StarterStationLayoutResolver.ResolvePowerModuleId(factionId, _staticData.ModuleDefinitions); EnsureStationModule(modules, powerModuleId); var defaultContainerStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds( powerModuleId, factionId, _staticData.ModuleDefinitions, _staticData.ItemDefinitions, _staticData.Recipes) .FirstOrDefault(moduleId => { return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) && definition is StorageModuleDefinition storageDefinition && storageDefinition.StorageKind == StorageKind.Container; }); if (defaultContainerStorageModuleId is not null) { EnsureStationModule(modules, defaultContainerStorageModuleId); } var defaultSolidStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds( powerModuleId, factionId, _staticData.ModuleDefinitions, _staticData.ItemDefinitions, _staticData.Recipes) .FirstOrDefault(moduleId => { return _staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition) && definition is StorageModuleDefinition storageDefinition && storageDefinition.StorageKind == StorageKind.Solid; }); if (defaultSolidStorageModuleId is not null) { EnsureStationModule(modules, defaultSolidStorageModuleId); } var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions); if (!string.IsNullOrWhiteSpace(objectiveModuleId)) { EnsureStationModule(modules, objectiveModuleId); foreach (var storageModuleId in StarterStationLayoutResolver.ResolveRequiredStorageModuleIds( objectiveModuleId, factionId, _staticData.ModuleDefinitions, _staticData.ItemDefinitions, _staticData.Recipes)) { EnsureStationModule(modules, storageModuleId); } } return modules; } private static void EnsureStationModule(List modules, string moduleId) { if (!modules.Contains(moduleId, StringComparer.Ordinal)) { modules.Add(moduleId); } } private int CountFactionStationsInSystem(string factionId, string systemId) => _world.Stations.Count(candidate => string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal) && string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal)); private static string ToTitleCaseToken(string value) => string.Join(" ", value .Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries) .Select(part => part.Length == 0 ? part : char.ToUpperInvariant(part[0]) + part[1..])); private static DefaultBehaviorRuntime CreateSpawnBehavior( SpawnShipCommandRequest request, ShipDefinition definition, string systemId, StationRuntime? homeStation) { var requestedBehavior = request.BehaviorKind?.Trim(); if (!string.IsNullOrWhiteSpace(requestedBehavior)) { return new DefaultBehaviorRuntime { Kind = requestedBehavior, HomeSystemId = systemId, HomeStationId = homeStation?.Id, AreaSystemId = systemId, ItemId = string.Equals(requestedBehavior, LocalAutoMine, StringComparison.Ordinal) ? "ore" : null, }; } if (IsMiningShip(definition) && homeStation is not null) { return new DefaultBehaviorRuntime { Kind = LocalAutoMine, HomeSystemId = systemId, HomeStationId = homeStation.Id, AreaSystemId = systemId, }; } if (IsMiningShip(definition)) { return new DefaultBehaviorRuntime { Kind = LocalAutoMine, HomeSystemId = systemId, HomeStationId = null, AreaSystemId = systemId, ItemId = "ore", }; } return new DefaultBehaviorRuntime { Kind = HoldPosition, HomeSystemId = systemId, HomeStationId = homeStation?.Id, AreaSystemId = systemId, WaitSeconds = 4f, Radius = 24f, }; } 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.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-anchor", StringComparison.OrdinalIgnoreCase) && systemFilter is null && scope.AnchorId is not null) { systemFilter = ResolveAnchorSystemId(scope.AnchorId); } 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(), Anchors = delta.Anchors.Where((anchor) => systemFilter is null || anchor.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 : [], 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? ResolveAnchorSystemId(string anchorId) => _world.Anchors.FirstOrDefault((anchor) => anchor.Id == anchorId)?.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-anchor" => evt.ScopeKind == "universe" || evt.ScopeEntityId == systemFilter, _ => true, }; } private sealed record SubscriptionState(ObserverScope Scope, Channel Channel); }