1212 lines
49 KiB
C#
1212 lines
49 KiB
C#
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<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,
|
|
IStaticDataProvider staticData,
|
|
WorldSeedingService worldSeedingService,
|
|
IPlayerStateStore playerStateStore,
|
|
IPlayerIdentityResolver playerIdentityResolver,
|
|
PlayerFactionProjectionService playerFactionProjection,
|
|
IBalanceService balance,
|
|
IOptions<OrbitalSimulationOptions> 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? 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<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;
|
|
_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 ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
|
|
{
|
|
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
|
|
if (ship is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (ship.OrderQueue.Count >= 8)
|
|
{
|
|
throw new InvalidOperationException("Order queue is full.");
|
|
}
|
|
|
|
ship.OrderQueue.Add(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
|
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
.OrderByDescending(order => order.Priority)
|
|
.ThenBy(order => order.CreatedAtUtc)
|
|
.Select(order => order.Id)
|
|
.FirstOrDefault();
|
|
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.RemoveAll(order => order.Id == orderId);
|
|
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
? "gm-order"
|
|
: "gm-manual";
|
|
ship.ControlSourceId = ship.OrderQueue
|
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
.OrderByDescending(order => order.Priority)
|
|
.ThenBy(order => order.CreatedAtUtc)
|
|
.Select(order => order.Id)
|
|
.FirstOrDefault();
|
|
ship.ControlReason = ship.OrderQueue
|
|
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
|
|
.OrderByDescending(order => order.Priority)
|
|
.ThenBy(order => order.CreatedAtUtc)
|
|
.Select(order => order.Label ?? order.Kind)
|
|
.FirstOrDefault()
|
|
?? "manual-gm-control";
|
|
ship.NeedsReplan = true;
|
|
ship.LastReplanReason = "gm-order-removed";
|
|
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 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) =>
|
|
_world.Anchors
|
|
.Where(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal))
|
|
.Where(candidate => SpatialBuilder.IsConstructibleAnchorKind(candidate.Kind))
|
|
.OrderBy(candidate => candidate.Position.DistanceTo(position))
|
|
.FirstOrDefault();
|
|
|
|
private string? ResolveNearestAnchorId(string systemId, Vector3 position) =>
|
|
ResolveNearestConstructibleAnchor(systemId, position)?.Id;
|
|
|
|
private IReadOnlyList<string> BuildStarterStationModules(string factionId, string objective)
|
|
{
|
|
var modules = new List<string>();
|
|
|
|
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<string> 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<WorldDelta> Channel);
|
|
}
|