Refactor backend into domain-first slices

This commit is contained in:
2026-03-19 18:15:44 -04:00
parent 07a3142316
commit 9a5040cf1f
53 changed files with 94 additions and 140 deletions

View File

@@ -1,144 +0,0 @@
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.AI;
// ─── Planning State ────────────────────────────────────────────────────────────
public sealed class FactionPlanningState
{
public int MilitaryShipCount { get; set; }
public int MinerShipCount { get; set; }
public int TransportShipCount { get; set; }
public int ConstructorShipCount { get; set; }
public int ControlledSystemCount { get; set; }
public int TargetSystemCount { get; set; }
public bool HasShipFactory { get; set; }
public float OreStockpile { get; set; }
public float RefinedMetalsStockpile { get; set; }
public FactionPlanningState Clone() => (FactionPlanningState)MemberwiseClone();
internal static int ComputeTargetWarships(FactionPlanningState state)
{
var expansionDeficit = Math.Max(0, state.TargetSystemCount - state.ControlledSystemCount);
return Math.Max(2, (state.ControlledSystemCount * 2) + (expansionDeficit * 3));
}
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
public sealed class EnsureWarFleetGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "ensure-war-fleet";
public override bool IsSatisfied(FactionPlanningState state) =>
state.MilitaryShipCount >= FactionPlanningState.ComputeTargetWarships(state);
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = FactionPlanningState.ComputeTargetWarships(state) - state.MilitaryShipCount;
return deficit <= 0 ? 0f : 50f + (deficit * 10f);
}
}
public sealed class ExpandTerritoryGoal : GoapGoal<FactionPlanningState>
{
public override string Name => "expand-territory";
public override bool IsSatisfied(FactionPlanningState state) =>
state.ControlledSystemCount >= state.TargetSystemCount;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = state.TargetSystemCount - state.ControlledSystemCount;
return deficit <= 0 ? 0f : 80f + (deficit * 15f);
}
}
public sealed class EnsureMiningCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinMiners = 2;
public override string Name => "ensure-mining-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.MinerShipCount >= MinMiners;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinMiners - state.MinerShipCount;
return deficit <= 0 ? 0f : 70f + (deficit * 12f);
}
}
public sealed class EnsureConstructionCapacityGoal : GoapGoal<FactionPlanningState>
{
private const int MinConstructors = 1;
public override string Name => "ensure-construction-capacity";
public override bool IsSatisfied(FactionPlanningState state) => state.ConstructorShipCount >= MinConstructors;
public override float ComputePriority(FactionPlanningState state, SimulationWorld world, CommanderRuntime commander)
{
var deficit = MinConstructors - state.ConstructorShipCount;
return deficit <= 0 ? 0f : 60f + (deficit * 10f);
}
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class OrderShipProductionAction : GoapAction<FactionPlanningState>
{
private readonly string shipKind;
private readonly string shipId;
public OrderShipProductionAction(string shipKind, string shipId)
{
this.shipKind = shipKind;
this.shipId = shipId;
}
public override string Name => $"order-{shipId}-production";
public override float Cost => 1f;
public override bool CheckPreconditions(FactionPlanningState state) => state.HasShipFactory;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
switch (shipKind)
{
case "military": state.MilitaryShipCount++; break;
case "mining": state.MinerShipCount++; break;
case "transport": state.TransportShipCount++; break;
case "construction": state.ConstructorShipCount++; break;
}
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add($"produce-{shipKind}-ships");
}
}
public sealed class ExpandToSystemAction : GoapAction<FactionPlanningState>
{
public override string Name => "expand-to-system";
public override float Cost => 3f;
public override bool CheckPreconditions(FactionPlanningState state) =>
state.ConstructorShipCount > 0 && state.MilitaryShipCount >= 2;
public override FactionPlanningState ApplyEffects(FactionPlanningState state)
{
state.ControlledSystemCount++;
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
commander.ActiveDirectives.Add("expand-territory");
}
}

View File

@@ -1,94 +0,0 @@
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.AI;
public abstract class GoapAction<TState>
{
public abstract string Name { get; }
public abstract float Cost { get; }
public abstract bool CheckPreconditions(TState state);
public abstract TState ApplyEffects(TState state);
public abstract void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander);
}
public abstract class GoapGoal<TState>
{
public abstract string Name { get; }
public abstract bool IsSatisfied(TState state);
public abstract float ComputePriority(TState state, SimulationWorld world, CommanderRuntime commander);
}
public sealed class GoapPlan<TState>
{
public static readonly GoapPlan<TState> Empty = new() { Actions = [], TotalCost = 0f };
public required IReadOnlyList<GoapAction<TState>> Actions { get; init; }
public required float TotalCost { get; init; }
public int CurrentStep { get; set; }
public GoapAction<TState>? CurrentAction => CurrentStep < Actions.Count ? Actions[CurrentStep] : null;
public bool IsComplete => CurrentStep >= Actions.Count;
public void Advance() => CurrentStep++;
}
public sealed class GoapPlanner<TState>
{
private readonly Func<TState, TState> cloneState;
public GoapPlanner(Func<TState, TState> cloneState)
{
this.cloneState = cloneState;
}
public GoapPlan<TState>? Plan(
TState initialState,
GoapGoal<TState> goal,
IReadOnlyList<GoapAction<TState>> availableActions)
{
if (goal.IsSatisfied(initialState))
{
return GoapPlan<TState>.Empty;
}
var openSet = new PriorityQueue<PlanNode, float>();
openSet.Enqueue(new PlanNode(cloneState(initialState), [], 0f), 0f);
const int MaxIterations = 256;
var iterations = 0;
while (openSet.Count > 0 && iterations++ < MaxIterations)
{
var current = openSet.Dequeue();
if (goal.IsSatisfied(current.State))
{
return new GoapPlan<TState>
{
Actions = current.Actions,
TotalCost = current.Cost,
};
}
foreach (var action in availableActions)
{
if (!action.CheckPreconditions(current.State))
{
continue;
}
var newState = action.ApplyEffects(cloneState(current.State));
var newCost = current.Cost + action.Cost;
var newActions = new List<GoapAction<TState>>(current.Actions) { action };
openSet.Enqueue(new PlanNode(newState, newActions, newCost), newCost);
}
}
return null;
}
private sealed record PlanNode(
TState State,
IReadOnlyList<GoapAction<TState>> Actions,
float Cost);
}

View File

@@ -1,13 +0,0 @@
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.AI;
internal interface IShipBehaviorState
{
string Kind { get; }
void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world);
void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent);
}

View File

@@ -1,41 +0,0 @@
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.AI;
internal sealed class ShipBehaviorStateMachine
{
private readonly IReadOnlyDictionary<string, IShipBehaviorState> states;
private readonly IShipBehaviorState fallbackState;
private ShipBehaviorStateMachine(IReadOnlyDictionary<string, IShipBehaviorState> states, IShipBehaviorState fallbackState)
{
this.states = states;
this.fallbackState = fallbackState;
}
public static ShipBehaviorStateMachine CreateDefault()
{
var idleState = new IdleShipBehaviorState();
var knownStates = new IShipBehaviorState[]
{
idleState,
new PatrolShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining"),
new ConstructStationShipBehaviorState(),
};
return new ShipBehaviorStateMachine(
knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal),
idleState);
}
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) =>
Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent);
private IShipBehaviorState Resolve(string kind) =>
states.TryGetValue(kind, out var state) ? state : fallbackState;
}

View File

@@ -1,130 +0,0 @@
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.AI;
internal sealed class IdleShipBehaviorState : IShipBehaviorState
{
public string Kind => "idle";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
}
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
}
}
internal sealed class PatrolShipBehaviorState : IShipBehaviorState
{
public string Kind => "patrol";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
{
ship.DefaultBehavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
return;
}
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId,
Threshold = 18f,
};
}
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
if (controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
}
}
}
internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
{
private readonly string resourceItemId;
private readonly string requiredModule;
public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule)
{
Kind = kind;
this.resourceItemId = resourceItemId;
this.requiredModule = requiredModule;
}
public string Kind { get; }
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-node", "arrived"):
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
break;
case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station";
break;
case ("extract", "node-depleted"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = "unload";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
}
}
}
internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
{
public string Kind => "construct-station";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanStationConstruction(ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "deliver-to-site";
break;
case ("deliver-to-site", "construction-delivered"):
ship.DefaultBehavior.Phase = "build-site";
break;
case ("construct-module", "module-constructed"):
case ("build-site", "site-constructed"):
ship.DefaultBehavior.Phase = "travel-to-station";
ship.DefaultBehavior.ModuleId = null;
break;
}
}
}

View File

@@ -1,157 +0,0 @@
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.AI;
// ─── Planning State ────────────────────────────────────────────────────────────
public sealed class ShipPlanningState
{
public string ShipKind { get; set; } = string.Empty;
public bool HasMiningCapability { get; set; }
public bool FactionWantsOre { get; set; }
public bool FactionWantsExpansion { get; set; }
public string? CurrentObjective { get; set; }
public ShipPlanningState Clone() => (ShipPlanningState)MemberwiseClone();
}
// ─── Goals ─────────────────────────────────────────────────────────────────────
// A ship should always have an assigned objective. The planner picks the best one.
public sealed class AssignObjectiveGoal : GoapGoal<ShipPlanningState>
{
public override string Name => "assign-objective";
public override bool IsSatisfied(ShipPlanningState state) => state.CurrentObjective is not null;
public override float ComputePriority(ShipPlanningState state, SimulationWorld world, CommanderRuntime commander) =>
100f;
}
// ─── Actions ───────────────────────────────────────────────────────────────────
public sealed class SetMiningObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-mining-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
state.HasMiningCapability && state.FactionWantsOre;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "auto-mine";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "auto-mine", StringComparison.Ordinal))
{
return;
}
ship.DefaultBehavior.Kind = "auto-mine";
ship.DefaultBehavior.Phase = null;
ship.DefaultBehavior.NodeId = null;
}
}
public sealed class SetPatrolObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-patrol-objective";
public override float Cost => 2f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "military", StringComparison.Ordinal);
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "patrol";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "patrol", StringComparison.Ordinal))
{
return;
}
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
{
var station = world.Stations.FirstOrDefault(s =>
s.FactionId == ship.FactionId &&
string.Equals(s.SystemId, ship.SystemId, StringComparison.Ordinal));
if (station is not null)
{
var radius = station.Radius + 90f;
ship.DefaultBehavior.PatrolPoints.AddRange(
[
new Vector3(station.Position.X + radius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + radius),
new Vector3(station.Position.X - radius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - radius),
]);
}
}
ship.DefaultBehavior.Kind = "patrol";
}
}
public sealed class SetConstructionObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-construction-objective";
public override float Cost => 1f;
public override bool CheckPreconditions(ShipPlanningState state) =>
string.Equals(state.ShipKind, "construction", StringComparison.Ordinal) && state.FactionWantsExpansion;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "construct-station";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "construct-station", StringComparison.Ordinal))
{
return;
}
ship.DefaultBehavior.Kind = "construct-station";
ship.DefaultBehavior.Phase = null;
}
}
public sealed class SetIdleObjectiveAction : GoapAction<ShipPlanningState>
{
public override string Name => "set-idle-objective";
public override float Cost => 10f;
public override bool CheckPreconditions(ShipPlanningState state) => true;
public override ShipPlanningState ApplyEffects(ShipPlanningState state)
{
state.CurrentObjective = "idle";
return state;
}
public override void Execute(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null || string.Equals(ship.DefaultBehavior.Kind, "idle", StringComparison.Ordinal))
{
return;
}
ship.DefaultBehavior.Kind = "idle";
}
}

View File

@@ -1,9 +1,5 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using SpaceGame.Api.Simulation.Support;
using SpaceGame.Api.Simulation.Systems;
namespace SpaceGame.Api.Simulation.Engine;
namespace SpaceGame.Api.Simulation.Core;
public sealed class SimulationEngine
{

View File

@@ -1,10 +1,8 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Systems.StationSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
namespace SpaceGame.Api.Simulation.Core;
internal sealed class SimulationProjectionService
{

View File

@@ -1,30 +0,0 @@
namespace SpaceGame.Api.Simulation.Model;
public sealed class MarketOrderRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public string? StationId { get; init; }
public string? ConstructionSiteId { get; init; }
public required string Kind { get; init; }
public required string ItemId { get; init; }
public float Amount { get; init; }
public float RemainingAmount { get; set; }
public float Valuation { get; set; }
public float? ReserveThreshold { get; set; }
public string? PolicySetId { get; set; }
public string State { get; set; } = MarketOrderStateKinds.Open;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class PolicySetRuntime
{
public required string Id { get; init; }
public required string OwnerKind { get; init; }
public required string OwnerId { get; init; }
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
public string ConstructionAccessPolicy { get; set; } = "owner-only";
public string OperationalRangePolicy { get; set; } = "unrestricted";
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -1,36 +0,0 @@
namespace SpaceGame.Api.Simulation.Model;
public sealed class ClaimRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
public string State { get; set; } = ClaimStateKinds.Placed;
public float Health { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ConstructionSiteRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string CelestialId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }
public string? ClaimId { get; set; }
public string? StationId { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> RequiredItems { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> DeliveredItems { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float Progress { get; set; }
public string State { get; set; } = ConstructionSiteStateKinds.Planned;
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -1,75 +0,0 @@
using SpaceGame.Api.Simulation.AI;
namespace SpaceGame.Api.Simulation.Model;
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float PopulationTotal { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
public string? DefaultPolicySetId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CommanderRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string FactionId { get; init; }
public string? ParentCommanderId { get; set; }
public string? ControlledEntityId { get; set; }
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public HashSet<string> ActiveDirectives { get; } = new(StringComparer.Ordinal);
public string? ActiveGoalName { get; set; }
public string? ActiveActionName { get; set; }
public float ReplanTimer { get; set; }
public bool NeedsReplan { get; set; } = true;
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
public CommanderOrderRuntime? ActiveOrder { get; set; }
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
public FactionPlanningState? LastPlanningState { get; set; }
public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; }
}
public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }
public string? AreaSystemId { get; set; }
public int PatrolIndex { get; set; }
}
public sealed class CommanderOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public string? TargetEntityId { get; set; }
public string? DestinationNodeId { get; set; }
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class CommanderTaskRuntime
{
public required string Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}

View File

@@ -1,66 +0,0 @@
using SpaceGame.Api.Data;
namespace SpaceGame.Api.Simulation.Model;
public sealed class ShipRuntime
{
public required string Id { get; init; }
public required string SystemId { get; set; }
public required ShipDefinition Definition { get; init; }
public required string FactionId { get; init; }
public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; }
public required ShipSpatialStateRuntime SpatialState { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public ShipState State { get; set; } = ShipState.Idle;
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
public float ActionTimer { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public float Health { get; set; }
public string? TrackedActionKey { get; set; }
public float TrackedActionTotal { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; }
public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; }
}
public sealed class ControllerTaskRuntime
{
public required ControllerTaskKind Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
public string? ItemId { get; set; }
}

View File

@@ -1,231 +0,0 @@
namespace SpaceGame.Api.Simulation.Model;
public enum SpatialNodeKind
{
Star,
Planet,
Moon,
LagrangePoint,
}
public enum WorkStatus
{
Pending,
Active,
Completed,
}
public enum OrderStatus
{
Queued,
Accepted,
Completed,
}
public enum ShipState
{
Idle,
Arriving,
LocalFlight,
SpoolingWarp,
Warping,
SpoolingFtl,
Ftl,
CargoFull,
MiningApproach,
Mining,
NodeDepleted,
AwaitingDock,
DockingApproach,
Docking,
Docked,
Transferring,
Loading,
Unloading,
WaitingMaterials,
ConstructionBlocked,
Constructing,
DeliveringConstruction,
Blocked,
Undocking,
}
public enum ControllerTaskKind
{
Idle,
Travel,
Extract,
Dock,
Load,
Unload,
DeliverConstruction,
BuildConstructionSite,
ConstructModule,
Undock,
}
public static class SpaceLayerKinds
{
public const string UniverseSpace = "universe-space";
public const string GalaxySpace = "galaxy-space";
public const string SystemSpace = "system-space";
public const string LocalSpace = "local-space";
}
public static class MovementRegimeKinds
{
public const string LocalFlight = "local-flight";
public const string Warp = "warp";
public const string StargateTransit = "stargate-transit";
public const string FtlTransit = "ftl-transit";
}
public static class CommanderKind
{
public const string Faction = "faction";
public const string Station = "station";
public const string Ship = "ship";
public const string Fleet = "fleet";
public const string Sector = "sector";
public const string TaskGroup = "task-group";
}
public static class ShipTaskKinds
{
public const string Idle = "idle";
public const string LocalMove = "local-move";
public const string WarpToNode = "warp-to-node";
public const string UseStargate = "use-stargate";
public const string UseFtl = "use-ftl";
public const string Dock = "dock";
public const string Undock = "undock";
public const string LoadCargo = "load-cargo";
public const string UnloadCargo = "unload-cargo";
public const string MineNode = "mine-node";
public const string HarvestGas = "harvest-gas";
public const string DeliverToStation = "deliver-to-station";
public const string ClaimLagrangePoint = "claim-lagrange-point";
public const string BuildConstructionSite = "build-construction-site";
public const string EscortTarget = "escort-target";
public const string AttackTarget = "attack-target";
public const string DefendCelestial = "defend-celestial";
public const string Retreat = "retreat";
public const string HoldPosition = "hold-position";
}
public static class ShipOrderKinds
{
public const string DirectMove = "direct-move";
public const string TravelToNode = "travel-to-node";
public const string DockAtStation = "dock-at-station";
public const string DeliverCargo = "deliver-cargo";
public const string BuildAtSite = "build-at-site";
public const string AttackTarget = "attack-target";
public const string HoldPosition = "hold-position";
}
public static class ClaimStateKinds
{
public const string Placed = "placed";
public const string Activating = "activating";
public const string Active = "active";
public const string Destroyed = "destroyed";
}
public static class ConstructionSiteStateKinds
{
public const string Planned = "planned";
public const string Active = "active";
public const string Paused = "paused";
public const string Completed = "completed";
public const string Destroyed = "destroyed";
}
public static class MarketOrderKinds
{
public const string Buy = "buy";
public const string Sell = "sell";
}
public static class MarketOrderStateKinds
{
public const string Open = "open";
public const string PartiallyFilled = "partially-filled";
public const string Filled = "filled";
public const string Cancelled = "cancelled";
}
public static class SimulationEnumMappings
{
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
{
SpatialNodeKind.Star => "star",
SpatialNodeKind.Planet => "planet",
SpatialNodeKind.Moon => "moon",
SpatialNodeKind.LagrangePoint => "lagrange-point",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
public static string ToContractValue(this WorkStatus status) => status switch
{
WorkStatus.Pending => "pending",
WorkStatus.Active => "active",
WorkStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this OrderStatus status) => status switch
{
OrderStatus.Queued => "queued",
OrderStatus.Accepted => "accepted",
OrderStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this ShipState state) => state switch
{
ShipState.Idle => "idle",
ShipState.Arriving => "arriving",
ShipState.LocalFlight => "local-flight",
ShipState.SpoolingWarp => "spooling-warp",
ShipState.Warping => "warping",
ShipState.SpoolingFtl => "spooling-ftl",
ShipState.Ftl => "ftl",
ShipState.CargoFull => "cargo-full",
ShipState.MiningApproach => "mining-approach",
ShipState.Mining => "mining",
ShipState.NodeDepleted => "node-depleted",
ShipState.AwaitingDock => "awaiting-dock",
ShipState.DockingApproach => "docking-approach",
ShipState.Docking => "docking",
ShipState.Docked => "docked",
ShipState.Transferring => "transferring",
ShipState.Loading => "loading",
ShipState.Unloading => "unloading",
ShipState.WaitingMaterials => "waiting-materials",
ShipState.ConstructionBlocked => "construction-blocked",
ShipState.Constructing => "constructing",
ShipState.DeliveringConstruction => "delivering-construction",
ShipState.Blocked => "blocked",
ShipState.Undocking => "undocking",
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
};
public static string ToContractValue(this ControllerTaskKind kind) => kind switch
{
ControllerTaskKind.Idle => "idle",
ControllerTaskKind.Travel => "travel",
ControllerTaskKind.Extract => "extract",
ControllerTaskKind.Dock => "dock",
ControllerTaskKind.Load => "load",
ControllerTaskKind.Unload => "unload",
ControllerTaskKind.DeliverConstruction => "deliver-construction",
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
ControllerTaskKind.ConstructModule => "construct-module",
ControllerTaskKind.Undock => "undock",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
}

View File

@@ -1,15 +0,0 @@
namespace SpaceGame.Api.Simulation.Model;
public static class SimulationUnits
{
public const float KilometersPerAu = 149_597_870.7f;
public const float MetersPerKilometer = 1000f;
public static float AuToKilometers(float au) => au * KilometersPerAu;
public static float AuPerSecondToKilometersPerSecond(float auPerSecond) =>
auPerSecond * KilometersPerAu;
public static float MetersPerSecondToKilometersPerSecond(float metersPerSecond) =>
metersPerSecond / MetersPerKilometer;
}

View File

@@ -1,29 +0,0 @@
using SpaceGame.Api.Data;
namespace SpaceGame.Api.Simulation.Model;
public sealed class SimulationWorld
{
public required string Label { get; init; }
public required int Seed { get; init; }
public required BalanceDefinition Balance { get; init; }
public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<CelestialRuntime> Celestials { get; init; }
public required List<StationRuntime> Stations { get; init; }
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public required List<CommanderRuntime> Commanders { get; init; }
public required List<ClaimRuntime> Claims { get; init; }
public required List<ConstructionSiteRuntime> ConstructionSites { get; init; }
public required List<MarketOrderRuntime> MarketOrders { get; init; }
public required List<PolicySetRuntime> Policies { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public required Dictionary<string, ItemDefinition> ItemDefinitions { get; init; }
public required Dictionary<string, ModuleDefinition> ModuleDefinitions { get; init; }
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
public int TickIntervalMs { get; init; } = 200;
public double OrbitalTimeSeconds { get; set; }
public DateTimeOffset GeneratedAtUtc { get; set; }
}

View File

@@ -1,60 +0,0 @@
using SpaceGame.Api.Data;
namespace SpaceGame.Api.Simulation.Model;
public sealed class SystemRuntime
{
public required SolarSystemDefinition Definition { get; init; }
public required Vector3 Position { get; init; }
}
public sealed class ResourceNodeRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required Vector3 Position { get; set; }
public required string SourceKind { get; init; }
public required string ItemId { get; init; }
public string? CelestialId { get; set; }
public float OrbitRadius { get; init; }
public float OrbitPhase { get; init; }
public float OrbitInclination { get; init; }
public float OreRemaining { get; set; }
public float MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CelestialRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required SpatialNodeKind Kind { get; init; }
public required Vector3 Position { get; set; }
public float LocalSpaceRadius { get; init; }
public string? ParentNodeId { get; set; }
public string? OccupyingStructureId { get; set; }
public string? OrbitReferenceId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipSpatialStateRuntime
{
public string SpaceLayer { get; set; } = SpaceLayerKinds.LocalSpace;
public required string CurrentSystemId { get; set; }
public string? CurrentCelestialId { get; set; }
public Vector3? LocalPosition { get; set; }
public Vector3? SystemPosition { get; set; }
public string MovementRegime { get; set; } = MovementRegimeKinds.LocalFlight;
public string? DestinationNodeId { get; set; }
public ShipTransitRuntime? Transit { get; set; }
}
public sealed class ShipTransitRuntime
{
public required string Regime { get; init; }
public string? OriginNodeId { get; init; }
public string? DestinationNodeId { get; init; }
public DateTimeOffset? StartedAtUtc { get; set; }
public DateTimeOffset? ArrivalDueAtUtc { get; set; }
public float Progress { get; set; }
}

View File

@@ -1,47 +0,0 @@
namespace SpaceGame.Api.Simulation.Model;
public sealed class StationRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required string Label { get; set; }
public string Category { get; set; } = "station";
public string Color { get; set; } = "#8df0d2";
public required Vector3 Position { get; set; }
public float Radius { get; set; } = 24f;
public required string FactionId { get; init; }
public string? CelestialId { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public List<StationModuleRuntime> Modules { get; } = [];
public IEnumerable<string> InstalledModules => Modules.Select((module) => module.ModuleId);
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> ProductionLaneTimers { get; } = new(StringComparer.Ordinal);
public Dictionary<int, string> DockingPadAssignments { get; } = new();
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float Population { get; set; }
public float PopulationCapacity { get; set; }
public float WorkforceRequired { get; set; }
public float WorkforceEffectiveRatio { get; set; } = 0.1f;
public float PopulationGrowthProgress { get; set; }
public float ShipProductionProgressSeconds { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class StationModuleRuntime
{
public required string Id { get; init; }
public required string ModuleId { get; init; }
public float Health { get; set; }
public float MaxHealth { get; set; }
}
public sealed class ModuleConstructionRuntime
{
public required string ModuleId { get; init; }
public float ProgressSeconds { get; set; }
public float RequiredSeconds { get; init; }
public string AssignedConstructorShipId { get; set; } = string.Empty;
}

View File

@@ -1,36 +0,0 @@
namespace SpaceGame.Api.Simulation.Model;
public readonly record struct Vector3(float X, float Y, float Z)
{
public static Vector3 Zero => new(0f, 0f, 0f);
public float DistanceTo(Vector3 other)
{
var dx = X - other.X;
var dy = Y - other.Y;
var dz = Z - other.Z;
return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
}
public float LengthSquared() => (X * X) + (Y * Y) + (Z * Z);
public Vector3 MoveToward(Vector3 target, float maxDistance)
{
var delta = target.Subtract(this);
var distance = MathF.Sqrt(delta.LengthSquared());
if (distance <= 0.0001f || distance <= maxDistance)
{
return target;
}
var scale = maxDistance / distance;
return new Vector3(
X + (delta.X * scale),
Y + (delta.Y * scale),
Z + (delta.Z * scale));
}
public Vector3 Subtract(Vector3 other) => new(X - other.X, Y - other.Y, Z - other.Z);
public Vector3 Divide(float scalar) => MathF.Abs(scalar) <= 0.0001f ? Zero : new Vector3(X / scalar, Y / scalar, Z / scalar);
}

View File

@@ -1,6 +0,0 @@
namespace SpaceGame.Api.Simulation;
public sealed class OrbitalSimulationOptions
{
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
}

View File

@@ -1,302 +0,0 @@
using System.Text.Json;
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
internal sealed class DataCatalogLoader(string dataRoot)
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
internal ScenarioCatalog LoadCatalog()
{
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var scenario = Read<ScenarioDefinition>("scenario.json");
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json");
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
var balance = Read<BalanceDefinition>("balance.json");
var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules);
return new ScenarioCatalog(
authoredSystems,
scenario,
balance,
modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal));
}
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition scenario,
IReadOnlyList<string> availableSystemIds)
{
if (availableSystemIds.Count == 0)
{
return scenario;
}
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
? "sol"
: availableSystemIds[0];
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
InitialStations = scenario.InitialStations
.Select(station => new InitialStationDefinition
{
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex,
LagrangeSide = station.LagrangeSide,
Position = station.Position?.ToArray(),
})
.ToList(),
ShipFormations = scenario.ShipFormations
.Select(formation => new ShipFormationDefinition
{
ShipId = formation.ShipId,
Count = formation.Count,
Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId,
})
.ToList(),
PatrolRoutes = scenario.PatrolRoutes
.Select(route => new PatrolRouteDefinition
{
SystemId = ResolveSystemId(route.SystemId),
Points = route.Points.Select(point => point.ToArray()).ToList(),
})
.ToList(),
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
},
};
}
private T Read<T>(string fileName)
{
var path = Path.Combine(dataRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
?? throw new InvalidOperationException($"Unable to read {fileName}.");
}
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> modules) =>
modules
.Where(module => module.Construction is not null || module.Production.Count > 0)
.Select(module => new ModuleRecipeDefinition
{
ModuleId = module.Id,
Duration = module.Construction?.ProductionTime ?? module.Production[0].Time,
Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares)
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
})
.ToList();
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
{
var recipes = new List<RecipeDefinition>();
var preferredProducerByItemId = modules
.Where(module => module.Products.Count > 0)
.GroupBy(module => module.Products[0], StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id,
StringComparer.Ordinal);
foreach (var item in items)
{
if (item.Production.Count > 0)
{
foreach (var production in item.Production)
{
recipes.Add(new RecipeDefinition
{
Id = $"{item.Id}-{production.Method}-production",
Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})",
FacilityCategory = InferFacilityCategory(item),
Duration = production.Time,
Priority = InferRecipePriority(item),
RequiredModules = InferRequiredModules(item, preferredProducerByItemId),
Inputs = production.Wares
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = production.Amount,
},
],
});
}
continue;
}
if (item.Construction is null)
{
continue;
}
recipes.Add(new RecipeDefinition
{
Id = item.Construction.RecipeId ?? $"{item.Id}-production",
Label = item.Name,
FacilityCategory = item.Construction.FacilityCategory,
Duration = item.Construction.CycleTime,
Priority = item.Construction.Priority,
RequiredModules = item.Construction.RequiredModules.ToList(),
Inputs = item.Construction.Requirements
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
Outputs =
[
new RecipeOutputDefinition
{
ItemId = item.Id,
Amount = item.Construction.BatchSize,
},
],
});
}
foreach (var ship in ships)
{
if (ship.Construction is null)
{
continue;
}
recipes.Add(new RecipeDefinition
{
Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction",
Label = $"{ship.Label} Construction",
FacilityCategory = ship.Construction.FacilityCategory,
Duration = ship.Construction.CycleTime,
Priority = ship.Construction.Priority,
RequiredModules = ship.Construction.RequiredModules.ToList(),
Inputs = ship.Construction.Requirements
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
ShipOutputId = ship.Id,
});
}
return recipes;
}
private static string InferFacilityCategory(ItemDefinition item) =>
item.Group switch
{
"agricultural" or "food" or "pharmaceutical" or "water" => "farm",
_ => "station",
};
private static List<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> preferredProducerByItemId)
{
if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId))
{
return [moduleId];
}
return [];
}
private static int InferRecipePriority(ItemDefinition item) =>
item.Group switch
{
"energy" => 140,
"water" => 130,
"food" => 120,
"agricultural" => 110,
"refined" => 100,
"hightech" => 90,
"shiptech" => 80,
"pharmaceutical" => 70,
_ => 60,
};
private static List<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
{
foreach (var item in items)
{
if (string.IsNullOrWhiteSpace(item.Type))
{
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
}
}
return items;
}
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
{
foreach (var module in modules)
{
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
{
module.Products = [module.Product];
}
if (string.IsNullOrWhiteSpace(module.ProductionMode))
{
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
? "commanded"
: "passive";
}
if (module.WorkforceNeeded <= 0f)
{
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
}
}
return modules;
}
}
internal sealed record ScenarioCatalog(
List<SolarSystemDefinition> AuthoredSystems,
ScenarioDefinition Scenario,
BalanceDefinition Balance,
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions,
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes);

View File

@@ -1,173 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation;
internal static class LoaderSupport
{
internal const string DefaultFactionId = "sol-dominion";
internal const int WorldSeed = 1;
internal const float MinimumFactionCredits = 0f;
internal const float MinimumRefineryOre = 0f;
internal const float MinimumRefineryStock = 0f;
internal const float MinimumShipyardStock = 0f;
internal const float MinimumSystemSeparation = 3.2f;
internal const float LocalSpaceRadius = 10_000f;
internal static readonly string[] GeneratedSystemNames =
[
"Aquila Verge",
"Orion Fold",
"Draco Span",
"Lyra Shoal",
"Cygnus March",
"Vela Crossing",
"Carina Wake",
"Phoenix Rest",
"Hydra Loom",
"Cassio Reach",
"Lupus Chain",
"Pavo Line",
"Serpens Rise",
"Cetus Hollow",
"Delphin Crown",
"Volans Drift",
"Ara Bastion",
"Indus Veil",
"Pyxis Trace",
"Lacerta Bloom",
"Columba Shroud",
"Dorado Expanse",
"Reticulum Run",
"Norma Edge",
"Crux Horizon",
"Sagitta Corridor",
"Monoceros Deep",
"Eridan Spur",
"Centauri Shelf",
"Antlia Reach",
"Horologium Gate",
"Telescopium Strand",
];
internal static readonly StarProfile[] StarProfiles =
[
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
];
internal static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
new("ice", "sphere", "#c8e4ff", 5800f, 0.32f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 45000f, 1.40f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 25000f, 1.00f, 5, true),
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
];
internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
{
var raw = ToVector(values);
var relativeToSystem = new Vector3(
raw.X - system.Position.X,
raw.Y - system.Position.Y,
raw.Z - system.Position.Z);
return relativeToSystem.LengthSquared() < raw.LengthSquared()
? relativeToSystem
: raw;
}
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
}
station.Modules.Add(new StationModuleRuntime
{
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
ModuleId = moduleId,
Health = definition.Hull,
MaxHealth = definition.Hull,
});
station.Radius = GetStationRadius(moduleDefinitions, station);
}
internal static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
{
var totalArea = station.Modules
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
.Sum();
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
{
var length = MathF.Sqrt(vector.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return vector.Divide(length);
}
}
internal sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize);
internal sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}

View File

@@ -1,27 +0,0 @@
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation;
public sealed class ScenarioLoader
{
private readonly WorldBuilder _worldBuilder;
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{
var generationOptions = worldGeneration ?? new WorldGenerationOptions();
var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
var dataLoader = new DataCatalogLoader(dataRoot);
var generationService = new SystemGenerationService();
var spatialBuilder = new SpatialBuilder();
var seedingService = new WorldSeedingService();
_worldBuilder = new WorldBuilder(
generationOptions,
dataLoader,
generationService,
spatialBuilder,
seedingService);
}
public SimulationWorld Load() => _worldBuilder.Build();
}

View File

@@ -1,305 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
internal sealed class SpatialBuilder
{
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance)
{
var systemGraphs = systems.ToDictionary(
system => system.Definition.Id,
BuildSystemSpatialGraph,
StringComparer.Ordinal);
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList();
var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var system in systems)
{
var systemGraph = systemGraphs[system.Definition.Id];
foreach (var node in system.Definition.ResourceNodes)
{
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
nodes.Add(new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id,
OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
});
}
}
return new ScenarioSpatialLayout(systemGraphs, celestials, nodes);
}
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
{
var celestials = new List<CelestialRuntime>();
var lagrangeNodesByPlanetIndex = new Dictionary<int, Dictionary<string, CelestialRuntime>>();
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
{
AddCelestial(
celestials,
id: $"node-{system.Definition.Id}-star-{starIndex + 1}",
systemId: system.Definition.Id,
kind: SpatialNodeKind.Star,
position: Vector3.Zero,
localSpaceRadius: LocalSpaceRadius);
}
var primaryStarNodeId = $"node-{system.Definition.Id}-star-1";
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
var planet = system.Definition.Planets[planetIndex];
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
var planetPosition = ComputePlanetPosition(planet);
var planetCelestial = AddCelestial(
celestials,
id: planetNodeId,
systemId: system.Definition.Id,
kind: SpatialNodeKind.Planet,
position: planetPosition,
localSpaceRadius: LocalSpaceRadius,
parentNodeId: primaryStarNodeId);
var lagrangeNodes = new Dictionary<string, CelestialRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet))
{
var lagrangeCelestial = AddCelestial(
celestials,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}",
systemId: system.Definition.Id,
kind: SpatialNodeKind.LagrangePoint,
position: point.Position,
localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id,
orbitReferenceId: point.Designation);
lagrangeNodes[point.Designation] = lagrangeCelestial;
}
lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes;
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
{
var moon = planet.Moons[moonIndex];
var moonPosition = ComputeMoonPosition(planetPosition, moon);
AddCelestial(
celestials,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}",
systemId: system.Definition.Id,
kind: SpatialNodeKind.Moon,
position: moonPosition,
localSpaceRadius: LocalSpaceRadius,
parentNodeId: planetCelestial.Id);
}
}
return new SystemSpatialGraph(system.Definition.Id, celestials, lagrangeNodesByPlanetIndex);
}
private static CelestialRuntime AddCelestial(
ICollection<CelestialRuntime> celestials,
string id,
string systemId,
SpatialNodeKind kind,
Vector3 position,
float localSpaceRadius,
string? parentNodeId = null,
string? orbitReferenceId = null)
{
var celestial = new CelestialRuntime
{
Id = id,
SystemId = systemId,
Kind = kind,
Position = position,
LocalSpaceRadius = localSpaceRadius,
ParentNodeId = parentNodeId,
OrbitReferenceId = orbitReferenceId,
};
celestials.Add(celestial);
return celestial;
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
yield return new LagrangePointPlacement(
"L4",
Add(
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
{
var planetMassProxy = EstimatePlanetMassRatio(planet);
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
return MathF.Max(minimumOffset, hillLikeOffset);
}
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
var densityFactor = planet.PlanetType switch
{
"gas-giant" => 0.24f,
"ice-giant" => 0.18f,
"oceanic" => 0.95f,
"ice" => 0.7f,
_ => 1f,
};
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
return earthMasses / 332_946f;
}
internal static StationPlacement ResolveStationPlacement(
InitialStationDefinition plan,
SystemRuntime system,
SystemSpatialGraph graph,
IReadOnlyCollection<CelestialRuntime> existingCelestials)
{
if (plan.PlanetIndex is int planetIndex &&
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
{
var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial))
{
return new StationPlacement(lagrangeCelestial, lagrangeCelestial.Position);
}
}
if (plan.Position is { Length: 3 })
{
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredCelestial = existingCelestials
.Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy(c => c.Position.DistanceTo(targetPosition))
.FirstOrDefault()
?? existingCelestials
.Where(c => c.SystemId == system.Definition.Id)
.OrderBy(c => c.Position.DistanceTo(targetPosition))
.First();
return new StationPlacement(preferredCelestial, preferredCelestial.Position);
}
var fallbackCelestial = graph.Celestials
.FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet);
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
}
private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch
{
< 0 => "L4",
> 0 => "L5",
_ => "L1",
};
private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
{
if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
{
return null;
}
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
{
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId);
}
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId);
}
private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane)
{
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f);
var offset = new Vector3(
MathF.Cos(definition.Angle) * definition.RadiusOffset,
verticalOffset,
MathF.Sin(definition.Angle) * definition.RadiusOffset);
if (anchorCelestial is null)
{
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
}
return Add(anchorCelestial.Position, offset);
}
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
{
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm);
}
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
{
var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch);
var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius);
return Add(planetPosition, local);
}
internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)
{
var nearestCelestial = celestials
.Where(c => c.SystemId == systemId)
.OrderBy(c => c.Position.DistanceTo(position))
.FirstOrDefault();
return new ShipSpatialStateRuntime
{
CurrentSystemId = systemId,
SpaceLayer = SpaceLayerKinds.LocalSpace,
CurrentCelestialId = nearestCelestial?.Id,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKinds.LocalFlight,
};
}
}
internal sealed record ScenarioSpatialLayout(
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
List<CelestialRuntime> Celestials,
List<ResourceNodeRuntime> Nodes);
internal sealed record SystemSpatialGraph(
string SystemId,
List<CelestialRuntime> Celestials,
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
internal sealed record LagrangePointPlacement(string Designation, Vector3 Position);
internal sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position);

View File

@@ -1,488 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
internal sealed class SystemGenerationService
{
private const string SolSystemId = "sol";
private const string DevelopmentCompanionSystemId = "helios";
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems
.Select(CloneSystemDefinition)
.ToList();
internal List<SolarSystemDefinition> ExpandSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
int targetSystemCount)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (targetSystemCount <= 0)
{
return [];
}
if (systems.Count > targetSystemCount)
{
return TrimSystemsToTarget(systems, targetSystemCount);
}
if (systems.Count >= targetSystemCount || authoredSystems.Count == 0)
{
return systems;
}
var existingIds = systems
.Select(system => system.Id)
.ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(
authoredSystems.Select(system => ToVector(system.Position)).ToList(),
targetSystemCount - systems.Count);
for (var index = systems.Count; index < targetSystemCount; index += 1)
{
var template = authoredSystems[index % authoredSystems.Count];
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, index + 1);
while (!existingIds.Add(id))
{
id = $"{id}-x";
}
systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count]));
}
return systems;
}
private static List<SolarSystemDefinition> TrimSystemsToTarget(IReadOnlyList<SolarSystemDefinition> systems, int targetSystemCount)
{
var selected = new List<SolarSystemDefinition>(targetSystemCount);
void AddById(string systemId)
{
var system = systems.FirstOrDefault(candidate => string.Equals(candidate.Id, systemId, StringComparison.Ordinal));
if (system is not null && selected.All(candidate => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
{
selected.Add(system);
}
}
AddById(SolSystemId);
AddById(DevelopmentCompanionSystemId);
foreach (var system in systems)
{
if (selected.Count >= targetSystemCount)
{
break;
}
if (selected.Any(candidate => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
{
continue;
}
selected.Add(system);
}
if (selected.Count > 0 && selected.Count <= 4)
{
ApplyCompactGalaxyLayout(selected);
}
return selected;
}
private static void ApplyCompactGalaxyLayout(IReadOnlyList<SolarSystemDefinition> systems)
{
var compactPositions = new[]
{
new[] { 0f, 0f, 0f },
new[] { 2.6f, 0.02f, -0.42f },
new[] { -2.4f, -0.04f, 0.56f },
new[] { 0.52f, 0.04f, 2.48f },
};
for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1)
{
systems[index].Position = compactPositions[index];
}
}
private static SolarSystemDefinition CreateGeneratedSystem(
SolarSystemDefinition template,
string label,
string id,
int generatedIndex,
Vector3 position)
{
var starProfile = SelectStarProfile(generatedIndex);
var planets = BuildGeneratedPlanets(template, generatedIndex);
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList();
return new SolarSystemDefinition
{
Id = id,
Label = label,
Position = [position.X, position.Y, position.Z],
Stars =
[
new StarDefinition
{
Kind = starProfile.Kind,
Color = starProfile.StarColor,
Glow = starProfile.StarGlow,
Size = starProfile.BaseSize + ((generatedIndex % 4) * 2f),
},
],
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10),
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18000f),
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12000f),
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4000f),
},
ResourceNodes = resourceNodes,
Planets = planets,
};
}
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
{
return new SolarSystemDefinition
{
Id = definition.Id,
Label = definition.Label,
Position = definition.Position.ToArray(),
Stars = definition.Stars.Select(s => new StarDefinition { Kind = s.Kind, Color = s.Color, Glow = s.Glow, Size = s.Size, OrbitRadius = s.OrbitRadius, OrbitSpeed = s.OrbitSpeed, OrbitPhaseAtEpoch = s.OrbitPhaseAtEpoch }).ToList(),
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = definition.AsteroidField.DecorationCount,
RadiusOffset = definition.AsteroidField.RadiusOffset,
RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance,
},
ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}).ToList(),
Planets = definition.Planets.Select(planet => new PlanetDefinition
{
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
Moons = planet.Moons.Select(moon => new MoonDefinition { Label = moon.Label, Size = moon.Size, Color = moon.Color, OrbitRadius = moon.OrbitRadius, OrbitSpeed = moon.OrbitSpeed, OrbitPhaseAtEpoch = moon.OrbitPhaseAtEpoch, OrbitInclination = moon.OrbitInclination, OrbitLongitudeOfAscendingNode = moon.OrbitLongitudeOfAscendingNode }).ToList(),
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
OrbitInclination = planet.OrbitInclination,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
Size = planet.Size,
Color = planet.Color,
Tilt = planet.Tilt,
HasRing = planet.HasRing,
}).ToList(),
};
}
private static List<ResourceNodeDefinition> BuildProceduralResourceNodes(
SolarSystemDefinition template,
IReadOnlyList<PlanetDefinition> planets,
int generatedIndex)
{
var nodes = new List<ResourceNodeDefinition>();
if (template.ResourceNodes.Count > 0)
{
nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}));
}
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
return nodes;
}
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
{
var allPositions = occupiedPositions.ToList();
var generated = new List<Vector3>(count);
for (var index = 0; index < count; index += 1)
{
Vector3? accepted = null;
for (var attempt = 0; attempt < 64; attempt += 1)
{
var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
accepted = candidate;
break;
}
}
accepted ??= ComputeFallbackGeneratedSystemPosition(index);
generated.Add(accepted.Value);
allPositions.Add(accepted.Value);
}
return generated;
}
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
{
const int armCount = 4;
const float baseInnerRadius = 9f;
const float radiusStep = 0.54f;
const float armOffset = MathF.PI * 2f / armCount;
var armIndex = (generatedIndex + attempt) % armCount;
var armDepth = generatedIndex / armCount;
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 0.9f);
var angle = (armIndex * armOffset) + (radius / 8.2f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
var x = MathF.Cos(angle) * radius;
var z = MathF.Sin(angle) * radius * 0.58f;
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
return new Vector3(x, y, z);
}
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
{
const int ringCount = 5;
const float fallbackRadius = 42f;
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1.8f;
return new Vector3(
MathF.Cos(angle) * radius,
ComputeSystemHeight(radius, generatedIndex, 99),
MathF.Sin(angle) * radius * 0.6f);
}
private static string BuildGeneratedSystemId(string label, int ordinal)
{
var slug = string.Concat(label
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '-'))
.Trim('-');
return $"gen-{ordinal}-{slug}";
}
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 1000f;
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = 120000f + Jitter(generatedIndex, 200 + index, 36000f),
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f),
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets),
OreAmount = oreAmount,
ItemId = "ore",
ShardCount = 6 + (index % 4),
};
}
}
private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)
{
if (planets.Count == 0)
{
return 0;
}
var gasGiantIndex = -1;
for (var index = 0; index < planets.Count; index += 1)
{
if (planets[index].PlanetType is "gas-giant" or "ice-giant")
{
gasGiantIndex = index;
break;
}
}
if (gasGiantIndex > 0)
{
return gasGiantIndex - 1;
}
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
}
private static List<PlanetDefinition> BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex)
{
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount);
var orbitRadius = 0.24f + (Hash01(generatedIndex, 3) * 0.12f);
var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null;
for (var index = 0; index < planetCount; index += 1)
{
var profile = SelectPlanetProfile(generatedIndex, index);
var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0
? sourcePlanets[index % sourcePlanets.Count]
: null;
orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin));
var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f);
var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f);
var moonCount = profile.BaseMoonCount + (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
var planetLabel = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}";
planets.Add(new PlanetDefinition
{
Label = planetLabel,
PlanetType = profile.Type,
Shape = profile.Shape,
Moons = GenerateMoons(planetLabel, profile.BaseSize, moonCount),
OrbitRadius = orbitRadius,
OrbitSpeed = 0.11f / MathF.Sqrt(MathF.Max(orbitRadius * orbitRadius * orbitRadius, 0.02f)),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f,
OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f,
OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f,
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * (profile.BaseSize * 0.35f)),
Color = templatePlanet?.Color ?? profile.Color,
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
});
}
return planets;
}
private static StarProfile SelectStarProfile(int generatedIndex)
{
var value = Hash01(generatedIndex, 80);
return value switch
{
< 0.32f => StarProfiles[0],
< 0.54f => StarProfiles[1],
< 0.68f => StarProfiles[5],
< 0.8f => StarProfiles[2],
< 0.9f => StarProfiles[3],
< 0.97f => StarProfiles[6],
_ => StarProfiles[4],
};
}
private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex)
{
var value = Hash01(generatedIndex, 90 + planetIndex);
return value switch
{
< 0.14f => PlanetProfiles[7],
< 0.28f => PlanetProfiles[0],
< 0.46f => PlanetProfiles[3],
< 0.62f => PlanetProfiles[1],
< 0.74f => PlanetProfiles[2],
< 0.86f => PlanetProfiles[4],
< 0.94f => PlanetProfiles[6],
_ => PlanetProfiles[5],
};
}
private static string BuildPlanetBaseName(int generatedIndex, int planetIndex)
{
var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length]
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0];
return source[..Math.Min(source.Length, 6)];
}
private static float ComputeSystemHeight(float radius, int generatedIndex, int salt)
{
var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8f) / 28f));
var band = 0.22f + (normalized * 0.76f);
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
}
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
private static float Hash01(int index, int salt)
{
uint value = (uint)(index + 1);
value ^= (uint)(salt + 0x9e3779b9);
value *= 0x85ebca6b;
value ^= value >> 13;
value *= 0xc2b2ae35;
value ^= value >> 16;
return (value & 0x00ffffff) / 16777215f;
}
private static List<MoonDefinition> GenerateMoons(string planetLabel, float planetSize, int moonCount)
{
var seed = planetLabel.Aggregate(0, (acc, c) => acc * 31 + c);
var moons = new List<MoonDefinition>(moonCount);
for (var moonIndex = 0; moonIndex < moonCount; moonIndex += 1)
{
var spacing = planetSize * 1.4f;
var radiusVariance = Hash01(seed, 10 + moonIndex) * planetSize * 0.9f;
var orbitRadius = (planetSize * 1.8f) + (moonIndex * spacing) + radiusVariance;
var orbitSpeed = 0.9f / MathF.Sqrt(MathF.Max(orbitRadius, 1f)) + (moonIndex * 0.003f);
var phase = Hash01(seed, 20 + moonIndex) * 360f;
var inclination = (Hash01(seed, 30 + moonIndex) - 0.5f) * 28f;
var ascendingNode = Hash01(seed, 40 + moonIndex) * 360f;
var sizeBase = MathF.Max(2.2f, planetSize * 0.11f);
var sizeVariance = Hash01(seed, 50 + moonIndex) * MathF.Max(planetSize * 0.16f, 2.5f);
var size = MathF.Min(sizeBase + sizeVariance, planetSize * 0.42f);
moons.Add(new MoonDefinition
{
Label = $"{planetLabel}-m{moonIndex + 1}",
Size = size,
Color = "#c8c4bc",
OrbitRadius = orbitRadius,
OrbitSpeed = orbitSpeed,
OrbitPhaseAtEpoch = phase,
OrbitInclination = inclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
});
}
return moons;
}
}

View File

@@ -1,182 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
internal sealed class WorldBuilder(
WorldGenerationOptions worldGeneration,
DataCatalogLoader dataLoader,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder,
WorldSeedingService seedingService)
{
internal SimulationWorld Build()
{
var catalog = dataLoader.LoadCatalog();
var systems = generationService.ExpandSystems(
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
worldGeneration.TargetSystemCount);
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
catalog.Scenario,
systems.Select(system => system.Id).ToList());
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{
Definition = definition,
Position = ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance);
var stations = CreateStations(
scenario,
systemsById,
spatialLayout.SystemGraphs,
spatialLayout.Celestials,
catalog.ModuleDefinitions);
seedingService.InitializeStationStockpiles(stations);
var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, refinery);
var factions = seedingService.CreateFactions(stations, ships);
seedingService.BootstrapFactionEconomy(factions, stations);
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(stations, claims, catalog.ModuleRecipes);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = catalog.Balance,
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
Stations = stations,
Ships = ships,
Factions = factions,
Commanders = commanders,
Claims = claims,
ConstructionSites = constructionSites,
MarketOrders = marketOrders,
Policies = policies,
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
private static List<StationRuntime> CreateStations(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions)
{
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
var station = new StationRuntime
{
Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id,
Label = plan.Label,
Color = plan.Color,
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
CelestialId = placement.AnchorCelestial.Id,
};
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
foreach (var moduleId in startingModules)
{
AddStationModule(station, moduleDefinitions, moduleId);
}
}
return stations;
}
private static Dictionary<string, List<Vector3>> BuildPatrolRoutes(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById)
{
return scenario.PatrolRoutes
.GroupBy(route => route.SystemId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group
.SelectMany(route => route.Points)
.Select(point => NormalizeScenarioPoint(systemsById[group.Key], point))
.ToList(),
StringComparer.Ordinal);
}
private static List<ShipRuntime> CreateShips(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials,
BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery)
{
var ships = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
continue;
}
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
ships.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? DefaultFactionId,
Position = position,
TargetPosition = position,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
DefaultBehavior = WorldSeedingService.CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth,
});
}
}
return ships;
}
}

View File

@@ -1,436 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
internal sealed class WorldSeedingService
{
internal List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var factionIds = stations
.Select(station => station.FactionId)
.Concat(ships.Select(ship => ship.FactionId))
.Where(factionId => !string.IsNullOrWhiteSpace(factionId))
.Distinct(StringComparer.Ordinal)
.OrderBy(factionId => factionId, StringComparer.Ordinal)
.ToList();
if (factionIds.Count == 0)
{
factionIds.Add(DefaultFactionId);
}
return factionIds.Select(CreateFaction).ToList();
}
internal void BootstrapFactionEconomy(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations)
{
foreach (var faction in factions)
{
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
var ownedStations = stations
.Where(station => station.FactionId == faction.Id)
.ToList();
var refineries = ownedStations
.Where(station => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"))
.ToList();
if (refineries.Count > 0)
{
foreach (var refinery in refineries)
{
refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock);
}
if (refineries.All(station => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
{
refineries[0].Inventory["ore"] = MinimumRefineryOre;
}
}
foreach (var shipyard in ownedStations.Where(station => HasInstalledModules(station, "module_gen_build_l_01")))
{
shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock);
}
}
}
internal void InitializeStationStockpiles(IReadOnlyCollection<StationRuntime> stations)
{
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refinedmetals"] = 120f;
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
}
}
}
internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
{
return stations.FirstOrDefault(station =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault(station =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
}
internal List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<CelestialRuntime> celestials,
DateTimeOffset nowUtc)
{
var stationsByCelestialId = stations
.Where(station => station.CelestialId is not null)
.ToDictionary(station => station.CelestialId!, StringComparer.Ordinal);
var claims = new List<ClaimRuntime>();
foreach (var celestial in celestials.Where(c => c.Kind == SpatialNodeKind.LagrangePoint))
{
if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station))
{
continue;
}
claims.Add(new ClaimRuntime
{
Id = $"claim-{celestial.Id}",
FactionId = station.FactionId,
SystemId = celestial.SystemId,
CelestialId = celestial.Id,
PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating,
Health = 100f,
});
}
return claims;
}
internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
var sites = new List<ConstructionSiteRuntime>();
var orders = new List<MarketOrderRuntime>();
foreach (var station in stations)
{
var moduleId = GetNextConstructionSiteModule(station, moduleRecipes);
if (moduleId is null || station.CelestialId is null)
{
continue;
}
var claim = claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
{
continue;
}
var site = new ConstructionSiteRuntime
{
Id = $"site-{station.Id}",
FactionId = station.FactionId,
SystemId = station.SystemId,
CelestialId = station.CelestialId,
TargetKind = "station-module",
TargetDefinitionId = "station",
BlueprintId = moduleId,
ClaimId = claim.Id,
StationId = station.Id,
State = claim.State == ClaimStateKinds.Active ? ConstructionSiteStateKinds.Active : ConstructionSiteStateKinds.Planned,
};
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{moduleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
orders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
sites.Add(site);
}
return (sites, orders);
}
internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{
var policies = new List<PolicySetRuntime>(factions.Count);
foreach (var faction in factions)
{
var policyId = $"policy-{faction.Id}";
faction.DefaultPolicySetId = policyId;
policies.Add(new PolicySetRuntime
{
Id = policyId,
OwnerKind = "faction",
OwnerId = faction.Id,
});
}
return policies;
}
internal List<CommanderRuntime> CreateCommanders(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var commanders = new List<CommanderRuntime>();
var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal);
var factionsById = factions.ToDictionary(faction => faction.Id, StringComparer.Ordinal);
foreach (var faction in factions)
{
var commander = new CommanderRuntime
{
Id = $"commander-faction-{faction.Id}",
Kind = CommanderKind.Faction,
FactionId = faction.Id,
ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-expansionist",
};
commander.Goals.Add("control-all-systems");
commander.Goals.Add("control-five-systems-fast");
commander.Goals.Add("expand-industrial-base");
commander.Goals.Add("grow-war-fleet");
commander.Goals.Add("deter-pirate-harassment");
commander.Goals.Add("contest-rival-expansion");
commanders.Add(commander);
factionCommanders[faction.Id] = commander;
faction.CommanderIds.Add(commander.Id);
}
foreach (var station in stations)
{
if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-station-{station.Id}",
Kind = CommanderKind.Station,
FactionId = station.FactionId,
ParentCommanderId = parentCommander.Id,
ControlledEntityId = station.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "station-default",
};
station.CommanderId = commander.Id;
station.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
factionsById[station.FactionId].CommanderIds.Add(commander.Id);
commanders.Add(commander);
}
foreach (var ship in ships)
{
if (!factionCommanders.TryGetValue(ship.FactionId, out var parentCommander))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-ship-{ship.Id}",
Kind = CommanderKind.Ship,
FactionId = ship.FactionId,
ParentCommanderId = parentCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "ship-default",
ActiveBehavior = CopyBehavior(ship.DefaultBehavior),
ActiveTask = CopyTask(ship.ControllerTask, null),
};
if (ship.Order is not null)
{
commander.ActiveOrder = CopyOrder(ship.Order);
}
ship.CommanderId = commander.Id;
ship.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
factionsById[ship.FactionId].CommanderIds.Add(commander.Id);
commanders.Add(commander);
}
return commanders;
}
internal static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery)
{
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
StationId = refinery.Id,
Phase = "travel-to-station",
};
}
if (HasCapabilities(definition, "mining") && refinery is not null)
{
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
}
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints = route,
PatrolIndex = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = "idle",
};
}
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static string? GetNextConstructionSiteModule(
StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
})
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& moduleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.Population = habitatModules > 0
? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f)
: MathF.Min(28f, station.PopulationCapacity);
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
{
Kind = kind,
AreaSystemId = areaSystemId,
StationId = stationId,
Phase = "travel-to-node",
};
private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
{
Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId,
ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId,
Phase = behavior.Phase,
PatrolIndex = behavior.PatrolIndex,
StationId = behavior.StationId,
};
private static CommanderOrderRuntime CopyOrder(ShipOrderRuntime order) => new()
{
Kind = order.Kind,
Status = order.Status,
DestinationSystemId = order.DestinationSystemId,
DestinationPosition = order.DestinationPosition,
};
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
{
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = targetNodeId ?? task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}

View File

@@ -1,19 +0,0 @@
namespace SpaceGame.Api.Simulation;
public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
try
{
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
worldService.Tick(0.2f);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
}

View File

@@ -1,183 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation.Support;
internal static class SimulationRuntimeSupport
{
internal static bool HasShipCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(cap => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
internal static int CountStationModules(StationRuntime station, string moduleId) =>
station.Modules.Count(module => string.Equals(module.ModuleId, moduleId, StringComparison.Ordinal));
internal static void AddStationModule(SimulationWorld world, StationRuntime station, string moduleId)
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
}
station.Modules.Add(new StationModuleRuntime
{
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
ModuleId = moduleId,
Health = definition.Hull,
MaxHealth = definition.Hull,
});
station.Radius = GetStationRadius(world, station);
}
internal static float GetStationRadius(SimulationWorld world, StationRuntime station)
{
var totalArea = station.Modules
.Select(module => world.ModuleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
.Sum();
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
internal static float GetStationStorageCapacity(StationRuntime station, string storageClass)
{
var baseCapacity = storageClass switch
{
"manufactured" => 400f,
_ => 0f,
};
var bulkBays = CountStationModules(station, "module_arg_stor_solid_m_01");
var liquidTanks = CountStationModules(station, "module_arg_stor_liquid_m_01");
var containerBays = CountStationModules(station, "module_arg_stor_container_m_01");
var moduleCapacity = storageClass switch
{
"solid" => bulkBays * 1000f,
"liquid" => liquidTanks * 500f,
"container" => containerBays * 800f,
"manufactured" => containerBays * 200f,
_ => 0f,
};
return baseCapacity + moduleCapacity;
}
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
internal static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
if (amount <= 0f)
{
return;
}
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
}
internal static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
var removed = MathF.Min(current, amount);
var remaining = current - removed;
if (remaining <= 0.001f)
{
inventory.Remove(itemId);
}
else
{
inventory[itemId] = remaining;
}
return removed;
}
internal static bool HasStationModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node, SimulationWorld world) =>
HasShipCapabilities(ship.Definition, "mining")
&& world.ItemDefinitions.TryGetValue(node.ItemId, out var item)
&& string.Equals(item.CargoKind, ship.Definition.CargoKind, StringComparison.Ordinal);
internal static bool CanBuildClaimBeacon(ShipRuntime ship) =>
string.Equals(ship.Definition.Kind, "military", StringComparison.Ordinal);
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
internal static string? GetStorageRequirement(string storageClass) =>
storageClass switch
{
"solid" => "module_arg_stor_solid_m_01",
"liquid" => "module_arg_stor_liquid_m_01",
_ => null,
};
internal static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return 0f;
}
var storageClass = itemDefinition.CargoKind;
var requiredModule = GetStorageRequirement(storageClass);
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
{
return 0f;
}
var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f)
{
return 0f;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == storageClass)
.Sum(entry => entry.Value);
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
if (accepted <= 0.01f)
{
return 0f;
}
AddInventory(station.Inventory, itemId, accepted);
return accepted;
}
internal static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
internal static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) =>
world.ConstructionSites.FirstOrDefault(site =>
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
internal static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
{
if (site.StationId is not null
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
{
return GetInventoryAmount(station.Inventory, itemId);
}
return GetInventoryAmount(site.DeliveredItems, itemId);
}
internal static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
internal static float GetShipCargoAmount(ShipRuntime ship) =>
ship.Inventory.Values.Sum();
}

View File

@@ -1,191 +0,0 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.AI;
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class CommanderPlanningService
{
private const float FactionCommanderReplanInterval = 10f;
private const float ShipCommanderReplanInterval = 5f;
private static readonly GoapPlanner<FactionPlanningState> _factionPlanner = new(s => s.Clone());
private static readonly GoapPlanner<ShipPlanningState> _shipPlanner = new(s => s.Clone());
private static readonly IReadOnlyList<GoapGoal<FactionPlanningState>> _factionGoals =
[
new ExpandTerritoryGoal(),
new EnsureWarFleetGoal(),
new EnsureMiningCapacityGoal(),
new EnsureConstructionCapacityGoal(),
];
private static readonly IReadOnlyList<GoapAction<ShipPlanningState>> _shipActions =
[
new SetMiningObjectiveAction(),
new SetPatrolObjectiveAction(),
new SetConstructionObjectiveAction(),
new SetIdleObjectiveAction(),
];
private static readonly GoapGoal<ShipPlanningState> _shipGoal = new AssignObjectiveGoal();
internal void UpdateCommanders(SimulationEngine engine, SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
// Faction commanders run first so their directives are available to ship commanders in the same tick.
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Faction)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateFactionCommander(engine, world, commander);
}
foreach (var commander in world.Commanders)
{
if (!commander.IsAlive || commander.Kind != CommanderKind.Ship)
{
continue;
}
TickCommander(commander, deltaSeconds);
UpdateShipCommander(engine, world, commander);
}
}
private static void TickCommander(CommanderRuntime commander, float deltaSeconds)
{
if (commander.ReplanTimer > 0f)
{
commander.ReplanTimer = MathF.Max(0f, commander.ReplanTimer - deltaSeconds);
}
}
private void UpdateFactionCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = FactionCommanderReplanInterval;
commander.NeedsReplan = false;
var state = BuildFactionPlanningState(world, commander.FactionId);
var actions = BuildFactionActions(world);
// Clear stale directives — actions will re-assert what is still needed.
commander.ActiveDirectives.Clear();
var rankedGoals = _factionGoals
.Select(g => (goal: g, priority: g.ComputePriority(state, world, commander)))
.Where(x => x.priority > 0f)
.OrderByDescending(x => x.priority)
.ToList();
commander.LastPlanningState = state;
commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList();
// Execute the first action of each active goal's plan (top 3 to avoid conflicts).
foreach (var (goal, _) in rankedGoals.Take(3))
{
var plan = _factionPlanner.Plan(state, goal, actions);
plan?.CurrentAction?.Execute(engine, world, commander);
}
}
private void UpdateShipCommander(SimulationEngine engine, SimulationWorld world, CommanderRuntime commander)
{
if (commander.ReplanTimer > 0f && !commander.NeedsReplan)
{
return;
}
commander.ReplanTimer = ShipCommanderReplanInterval;
commander.NeedsReplan = false;
var ship = world.Ships.FirstOrDefault(s => s.Id == commander.ControlledEntityId);
if (ship is null)
{
return;
}
var state = BuildShipPlanningState(world, ship, commander);
var plan = _shipPlanner.Plan(state, _shipGoal, _shipActions);
if (plan?.CurrentAction is { } action)
{
commander.ActiveGoalName = _shipGoal.Name;
commander.ActiveActionName = action.Name;
action.Execute(engine, world, commander);
}
}
internal FactionPlanningState BuildFactionPlanningState(SimulationWorld world, string factionId)
{
var stations = world.Stations.Where(s => s.FactionId == factionId).ToList();
return new FactionPlanningState
{
MilitaryShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "military", StringComparison.Ordinal)),
MinerShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "mining", StringComparison.Ordinal)),
TransportShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "transport", StringComparison.Ordinal)),
ConstructorShipCount = world.Ships.Count(s =>
s.FactionId == factionId &&
string.Equals(s.Definition.Kind, "construction", StringComparison.Ordinal)),
ControlledSystemCount = StationSimulationService.GetFactionControlledSystemsCount(world, factionId),
TargetSystemCount = Math.Max(1, Math.Min(StationSimulationService.StrategicControlTargetSystems, world.Systems.Count)),
HasShipFactory = stations.Any(s => s.InstalledModules.Contains("module_gen_build_l_01", StringComparer.Ordinal)),
OreStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "ore")),
RefinedMetalsStockpile = stations.Sum(s => GetInventoryAmount(s.Inventory, "refinedmetals")),
};
}
private static ShipPlanningState BuildShipPlanningState(
SimulationWorld world,
ShipRuntime ship,
CommanderRuntime commander)
{
var factionCommander = world.Commanders.FirstOrDefault(c =>
c.FactionId == commander.FactionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal));
return new ShipPlanningState
{
ShipKind = ship.Definition.Kind,
HasMiningCapability = HasShipCapabilities(ship.Definition, "mining"),
FactionWantsOre = true,
FactionWantsExpansion = factionCommander?.ActiveDirectives
.Contains("expand-territory", StringComparer.Ordinal) ?? false,
};
}
private static IReadOnlyList<GoapAction<FactionPlanningState>> BuildFactionActions(SimulationWorld world)
{
var actions = new List<GoapAction<FactionPlanningState>>();
foreach (var (shipId, def) in world.ShipDefinitions)
{
actions.Add(new OrderShipProductionAction(def.Kind, shipId));
}
actions.Add(new ExpandToSystemAction());
return actions;
}
internal static bool FactionCommanderHasDirective(SimulationWorld world, string factionId, string directive) =>
world.Commanders.FirstOrDefault(c =>
c.FactionId == factionId &&
string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal))
?.ActiveDirectives.Contains(directive, StringComparer.Ordinal) ?? false;
}

View File

@@ -1,334 +0,0 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class InfrastructureSimulationService
{
internal void UpdateClaims(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var claim in world.Claims)
{
if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f)
{
if (claim.State != ClaimStateKinds.Destroyed)
{
claim.State = ClaimStateKinds.Destroyed;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc));
}
foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id))
{
site.State = ConstructionSiteStateKinds.Destroyed;
}
continue;
}
if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc)
{
claim.State = ClaimStateKinds.Active;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc));
}
}
}
internal void UpdateConstructionSites(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var site in world.ConstructionSites)
{
if (site.State == ConstructionSiteStateKinds.Destroyed)
{
continue;
}
var claim = site.ClaimId is null
? null
: world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId);
if (claim?.State == ClaimStateKinds.Destroyed)
{
site.State = ConstructionSiteStateKinds.Destroyed;
continue;
}
if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned)
{
site.State = ConstructionSiteStateKinds.Active;
events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc));
}
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required))
{
continue;
}
var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId));
order.RemainingAmount = remaining;
order.State = remaining <= 0.01f
? MarketOrderStateKinds.Filled
: remaining < order.Amount
? MarketOrderStateKinds.PartiallyFilled
: MarketOrderStateKinds.Open;
}
}
}
internal static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
{
if (station.ActiveConstruction is not null)
{
return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal)
&& string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal);
}
if (!CanStartModuleConstruction(station, recipe))
{
return false;
}
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
station.ActiveConstruction = new ModuleConstructionRuntime
{
ModuleId = recipe.ModuleId,
RequiredSeconds = recipe.Duration,
AssignedConstructorShipId = shipId,
};
return true;
}
internal static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{
// Expand storage before it becomes a bottleneck
const float StorageExpansionThreshold = 0.85f;
var storageExpansionCandidates = new[]
{
("solid", "module_arg_stor_solid_m_01"),
("liquid", "module_arg_stor_liquid_m_01"),
("container", "module_arg_stor_container_m_01"),
};
foreach (var (storageClass, moduleId) in storageExpansionCandidates)
{
var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f)
{
continue;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var def) && def.CargoKind == storageClass)
.Sum(entry => entry.Value);
if (used / capacity >= StorageExpansionThreshold && world.ModuleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
var priorities = StationSimulationService.GetFactionExpansionPressure(world, station.FactionId) > 0f
? new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_arg_dock_m_01_lowtech", 2),
("module_gen_prod_energycells_01", 2),
}
: new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_solid_m_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
};
foreach (var (moduleId, targetCount) in priorities)
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& world.ModuleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
internal static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
{
var nextModuleId = GetNextStationModuleToBuild(station, world);
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is not null)
{
order.State = MarketOrderStateKinds.Cancelled;
order.RemainingAmount = 0f;
world.MarketOrders.Remove(order);
}
station.MarketOrderIds.Remove(orderId);
}
site.MarketOrderIds.Clear();
site.Inventory.Clear();
site.DeliveredItems.Clear();
site.RequiredItems.Clear();
site.AssignedConstructorShipIds.Clear();
site.Progress = 0f;
if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe))
{
site.State = ConstructionSiteStateKinds.Completed;
site.BlueprintId = null;
return;
}
site.BlueprintId = nextModuleId;
site.State = ConstructionSiteStateKinds.Active;
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
world.MarketOrders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
}
internal static int GetDockingPadCount(StationRuntime station) =>
CountModules(station.InstalledModules, "module_arg_dock_m_01_lowtech") * 2;
internal static int? ReserveDockingPad(StationRuntime station, string shipId)
{
if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing
&& !string.IsNullOrEmpty(existing.Value))
{
return existing.Key;
}
var padCount = GetDockingPadCount(station);
for (var padIndex = 0; padIndex < padCount; padIndex += 1)
{
if (station.DockingPadAssignments.ContainsKey(padIndex))
{
continue;
}
station.DockingPadAssignments[padIndex] = shipId;
return padIndex;
}
return null;
}
internal static void ReleaseDockingPad(StationRuntime station, string shipId)
{
var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal));
if (!string.IsNullOrEmpty(assignment.Value))
{
station.DockingPadAssignments.Remove(assignment.Key);
}
}
internal static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex)
{
var padCount = Math.Max(1, GetDockingPadCount(station));
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
var radius = station.Radius + 18f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
internal static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Radius + 24f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
internal static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance)
{
if (padIndex is null)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var pad = GetDockingPadPosition(station, padIndex.Value);
var dx = pad.X - station.Position.X;
var dz = pad.Z - station.Position.Z;
var length = MathF.Sqrt((dx * dx) + (dz * dz));
if (length <= 0.001f)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var scale = distance / length;
return new Vector3(
pad.X + (dx * scale),
station.Position.Y,
pad.Z + (dz * scale));
}
internal static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) =>
ship.AssignedDockingPadIndex is int padIndex
? GetDockingPadPosition(station, padIndex)
: station.Position;
internal static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Radius + 78f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
internal static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
return new Vector3(
nodePosition.X + (MathF.Cos(angle) * radius),
nodePosition.Y,
nodePosition.Z + (MathF.Sin(angle) * radius));
}
}

View File

@@ -1,295 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class OrbitalStateUpdater
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
internal OrbitalStateUpdater(OrbitalSimulationOptions orbitalSimulation)
{
_orbitalSimulation = orbitalSimulation;
}
private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds)
{
var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f);
var meanAnomaly = DegreesToRadians(planet.OrbitPhaseAtEpoch) + (timeSeconds * planet.OrbitSpeed);
var eccentricAnomaly = meanAnomaly
+ (eccentricity * MathF.Sin(meanAnomaly))
+ (0.5f * eccentricity * eccentricity * MathF.Sin(2f * meanAnomaly));
var semiMajorAxis = SimulationUnits.AuToKilometers(planet.OrbitRadius);
var semiMinorAxis = semiMajorAxis * MathF.Sqrt(MathF.Max(1f - (eccentricity * eccentricity), 0.05f));
var local = new Vector3(
semiMajorAxis * (MathF.Cos(eccentricAnomaly) - eccentricity),
0f,
semiMinorAxis * MathF.Sin(eccentricAnomaly));
local = RotateAroundY(local, DegreesToRadians(planet.OrbitArgumentOfPeriapsis));
local = RotateAroundX(local, DegreesToRadians(planet.OrbitInclination));
local = RotateAroundY(local, DegreesToRadians(planet.OrbitLongitudeOfAscendingNode));
return local;
}
private static Vector3 ComputeMoonOffset(MoonDefinition moon, float timeSeconds)
{
var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch) + (timeSeconds * moon.OrbitSpeed);
var local = new Vector3(
MathF.Cos(angle) * moon.OrbitRadius,
0f,
MathF.Sin(angle) * moon.OrbitRadius);
local = RotateAroundX(local, DegreesToRadians(moon.OrbitInclination));
local = RotateAroundY(local, DegreesToRadians(moon.OrbitLongitudeOfAscendingNode));
return local;
}
private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
{
var baseSpeed = 0.24f;
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180000f, 0.45f));
}
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
{
var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node));
var orbit = new Vector3(
MathF.Cos(angle) * node.OrbitRadius,
0f,
MathF.Sin(angle) * node.OrbitRadius);
return RotateAroundX(orbit, node.OrbitInclination);
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
PlanetDefinition planet)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z);
var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet);
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm));
yield return new LagrangePointPlacement(
"L4",
Add(
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(
Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)),
Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet)
{
var planetMassProxy = EstimatePlanetMassRatio(planet);
var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f));
var minimumOffset = MathF.Max(planet.Size * 4f, 25000f);
return MathF.Max(minimumOffset, hillLikeOffset);
}
private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
var densityFactor = planet.PlanetType switch
{
"gas-giant" => 0.24f,
"ice-giant" => 0.18f,
"oceanic" => 0.95f,
"ice" => 0.7f,
_ => 1f,
};
var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor;
return earthMasses / 332_946f;
}
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
{
var length = MathF.Sqrt(value.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return value.Divide(length);
}
private static Vector3 RotateAroundX(Vector3 value, float angle)
{
var cos = MathF.Cos(angle);
var sin = MathF.Sin(angle);
return new Vector3(
value.X,
(value.Y * cos) - (value.Z * sin),
(value.Y * sin) + (value.Z * cos));
}
private static Vector3 RotateAroundY(Vector3 value, float angle)
{
var cos = MathF.Cos(angle);
var sin = MathF.Sin(angle);
return new Vector3(
(value.X * cos) + (value.Z * sin),
value.Y,
(-value.X * sin) + (value.Z * cos));
}
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
private static Vector3 Scale(Vector3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar);
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
private static float HashUnit(string input)
{
unchecked
{
var hash = 2166136261u;
foreach (var character in input)
{
hash ^= character;
hash *= 16777619u;
}
return (hash & 0x00FFFFFF) / (float)0x01000000;
}
}
internal void Update(SimulationWorld world)
{
var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
var celestialsById = world.Celestials.ToDictionary(c => c.Id, StringComparer.Ordinal);
foreach (var system in world.Systems)
{
for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1)
{
var star = system.Definition.Stars[starIndex];
var starNodeId = $"node-{system.Definition.Id}-star-{starIndex + 1}";
if (!celestialsById.TryGetValue(starNodeId, out var starNode))
{
continue;
}
if (star.OrbitRadius <= 0f)
{
starNode.Position = Vector3.Zero;
}
else
{
var angle = DegreesToRadians(star.OrbitPhaseAtEpoch) + (worldTimeSeconds * star.OrbitSpeed);
starNode.Position = new Vector3(MathF.Cos(angle) * star.OrbitRadius, 0f, MathF.Sin(angle) * star.OrbitRadius);
}
}
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
var planet = system.Definition.Planets[planetIndex];
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
if (!celestialsById.TryGetValue(planetNodeId, out var planetNode))
{
continue;
}
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
planetNode.Position = planetPosition;
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet))
{
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
if (celestialsById.TryGetValue(lagrangeId, out var lagrangeNode))
{
lagrangeNode.Position = lagrange.Position;
}
}
for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1)
{
var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
if (!celestialsById.TryGetValue(moonId, out var moonNode))
{
continue;
}
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet.Moons[moonIndex], worldTimeSeconds));
}
}
}
foreach (var station in world.Stations)
{
if (station.CelestialId is null || !celestialsById.TryGetValue(station.CelestialId, out var anchorCelestial))
{
continue;
}
station.Position = anchorCelestial.Position;
}
foreach (var node in world.Nodes)
{
if (node.CelestialId is null || !celestialsById.TryGetValue(node.CelestialId, out var anchorCelestial))
{
continue;
}
node.Position = Add(anchorCelestial.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
}
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
continue;
}
var dockedPosition = GetShipDockedPosition(ship, station);
ship.Position = dockedPosition;
ship.TargetPosition = dockedPosition;
}
}
internal void SyncSpatialState(SimulationWorld world)
{
foreach (var ship in world.Ships)
{
ship.SpatialState.CurrentSystemId = ship.SystemId;
ship.SpatialState.LocalPosition = ship.Position;
ship.SpatialState.SystemPosition = ship.Position;
if (ship.SpatialState.Transit is not null)
{
ship.SpatialState.CurrentCelestialId = null;
continue;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
var nearestCelestial = world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
ship.SpatialState.CurrentCelestialId = nearestCelestial?.Id;
if (ship.DockedStationId is null)
{
continue;
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station?.CelestialId is not null)
{
ship.SpatialState.CurrentCelestialId = station.CelestialId;
}
}
}
private readonly record struct LagrangePointPlacement(string Designation, Vector3 Position);
}

View File

@@ -1,570 +0,0 @@
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.AI;
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class ShipControlService
{
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship);
private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander)
{
if (commander.ActiveBehavior is not null)
{
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex;
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
}
if (commander.ActiveOrder is null)
{
ship.Order = null;
}
else
{
ship.Order = new ShipOrderRuntime
{
Kind = commander.ActiveOrder.Kind,
Status = commander.ActiveOrder.Status,
DestinationSystemId = commander.ActiveOrder.DestinationSystemId,
DestinationPosition = commander.ActiveOrder.DestinationPosition,
};
}
if (commander.ActiveTask is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
Status = commander.ActiveTask.Status,
CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId,
TargetNodeId = commander.ActiveTask.TargetNodeId,
TargetPosition = commander.ActiveTask.TargetPosition,
TargetSystemId = commander.ActiveTask.TargetSystemId,
Threshold = commander.ActiveTask.Threshold,
};
}
}
private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander)
{
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex;
commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId;
if (ship.Order is null)
{
commander.ActiveOrder = null;
}
else
{
commander.ActiveOrder ??= new CommanderOrderRuntime
{
Kind = ship.Order.Kind,
DestinationSystemId = ship.Order.DestinationSystemId,
DestinationPosition = ship.Order.DestinationPosition,
};
commander.ActiveOrder.Status = ship.Order.Status;
commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
}
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition;
commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId;
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
}
internal void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (commander is not null)
{
SyncCommanderToShip(ship, commander);
}
if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued)
{
ship.Order.Status = OrderStatus.Accepted;
if (commander?.ActiveOrder is not null)
{
commander.ActiveOrder.Status = ship.Order.Status;
}
}
if (commander is not null)
{
SyncShipToCommander(ship, commander);
}
}
internal void PlanControllerTask(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
Status = WorkStatus.Active,
CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId,
TargetNodeId = ship.SpatialState.DestinationNodeId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
SyncCommanderTask(commander, ship.ControllerTask);
return;
}
_shipBehaviorStateMachine.Plan(engine, ship, world);
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId);
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate =>
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
candidate.ItemId == resourceItemId &&
candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
if (refinery is null || node is null || !HasShipCapabilities(ship.Definition, requiredModule))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.NodeId ??= node.Id;
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
&& behavior.Phase is "travel-to-node" or "extract")
{
behavior.Phase = "travel-to-station";
}
if (ship.DockedStationId == refinery.Id)
{
if (GetShipCargoAmount(ship) > 0.01f)
{
behavior.Phase = "unload";
}
else if (behavior.Phase is "dock" or "unload")
{
behavior.Phase = "undock";
}
}
else if (behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "extract":
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Extract,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = extractionPosition,
Threshold = 5f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
Threshold = 8f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
? null
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
var bestOrder = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Buy &&
order.ConstructionSiteId is null &&
order.State != MarketOrderStateKinds.Cancelled &&
order.ItemId == itemId &&
order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
.Where(entry => entry.Station is not null)
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? preferred;
}
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
phase switch
{
"dock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"undock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
Threshold = 8f,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var site = station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId;
if (moduleId is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (ship.DockedStationId is not null)
{
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (dockedStation is not null)
{
dockedStation.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(dockedStation, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.Position = GetConstructionHoldPosition(station, ship.Id);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
var isAtConstructionHold = ship.SystemId == station.SystemId
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
if (isAtConstructionHold)
{
if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
{
behavior.Phase = "deliver-to-site";
}
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
{
behavior.Phase = "build-site";
}
else if (site is not null)
{
behavior.Phase = "wait-for-materials";
}
else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId]))
{
behavior.Phase = "construct-module";
}
else
{
behavior.Phase = "wait-for-materials";
}
}
else if (behavior.Phase != "travel-to-station")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
behavior.Phase = "travel-to-station";
break;
}
}
internal void AdvanceControlState(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
if (commander is not null)
{
commander.ActiveOrder = null;
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Completed,
TargetSystemId = ship.SystemId,
Threshold = 0f,
};
}
return;
}
_shipBehaviorStateMachine.ApplyEvent(engine, ship, world, controllerEvent);
if (commander is not null)
{
SyncShipToCommander(ship, commander);
if (commander.ActiveTask is not null)
{
commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed;
}
}
}
internal void TrackHistory(ShipRuntime ship, string controllerEvent)
{
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
var target = ship.ControllerTask.TargetEntityId
?? ship.ControllerTask.TargetSystemId
?? "none";
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
internal void EmitShipStateEvents(
ShipRuntime ship,
ShipState previousState,
string previousBehavior,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
internal static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = ControllerTaskKind.Idle,
Threshold = threshold,
};
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
{
"travel" => ControllerTaskKind.Travel,
"extract" => ControllerTaskKind.Extract,
"dock" => ControllerTaskKind.Dock,
"load" => ControllerTaskKind.Load,
"unload" => ControllerTaskKind.Unload,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
_ => ControllerTaskKind.Idle,
};
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
{
if (commander is null)
{
return;
}
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}
}

View File

@@ -1,458 +0,0 @@
using SpaceGame.Api.Simulation.Model;
using SpaceGame.Api.Simulation.Support;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed partial class ShipTaskExecutionService
{
private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds)
{
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < requiredSeconds)
{
return false;
}
ship.ActionTimer = 0f;
return true;
}
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
{
if (ship.TrackedActionKey == actionKey)
{
return;
}
ship.TrackedActionKey = actionKey;
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
}
internal static float GetShipCargoAmount(ShipRuntime ship) =>
SimulationRuntimeSupport.GetShipCargoAmount(ship);
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node, world))
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return "cargo-full";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.ActionTimer = 0f;
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Mining;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{
return "none";
}
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
ship.ActionTimer = 0f;
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
}
AddInventory(ship.Inventory, node.ItemId, mined);
node.OreRemaining -= mined;
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
}
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
if (padIndex is null)
{
ship.ActionTimer = 0f;
ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f)
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
}
return "none";
}
ship.AssignedDockingPadIndex = padIndex;
var padPosition = GetDockingPadPosition(station, padIndex.Value);
ship.TargetPosition = padPosition;
var distance = ship.Position.DistanceTo(padPosition);
if (distance > 4f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.State = ShipState.Docking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{
return "none";
}
ship.State = ShipState.Docked;
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition;
ship.TargetPosition = padPosition;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Transferring;
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
foreach (var (itemId, amount) in ship.Inventory.ToList())
{
var moved = MathF.Min(amount, world.Balance.TransferRate * deltaSeconds);
var accepted = TryAddStationInventory(world, station, itemId, moved);
RemoveInventory(ship.Inventory, itemId, accepted);
if (faction is not null && string.Equals(itemId, "ore", StringComparison.Ordinal))
{
faction.OreMined += accepted;
faction.Credits += accepted * 0.4f;
}
}
return GetShipCargoAmount(ship) <= 0.01f ? "unloaded" : "none";
}
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Loading;
var itemId = ship.ControllerTask.ItemId;
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var moved = itemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, itemId));
if (itemId is not null && moved > 0.01f)
{
RemoveInventory(station.Inventory, itemId, moved);
AddInventory(ship.Inventory, itemId, moved);
}
return itemId is null
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|| GetInventoryAmount(station.Inventory, itemId) <= 0.01f
? "loaded"
: "none";
}
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null || ship.DefaultBehavior.ModuleId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{
ship.AssignedDockingPadIndex = null;
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{
ship.ActionTimer = 0f;
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{
ship.State = ShipState.ConstructionBlocked;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
{
return "none";
}
AddStationModule(world, station, station.ActiveConstruction.ModuleId);
station.ActiveConstruction = null;
return "module-constructed";
}
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.DeliveringConstruction;
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
if (site.StationId is not null)
{
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key));
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration)
{
return "none";
}
AddStationModule(world, station, site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
return "site-constructed";
}
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
ship.DockedStationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
: null;
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
ship.DockedStationId is not null
? GetShipDockedPosition(ship, station)
: GetConstructionHoldPosition(station, ship.Id);
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var undockTarget = station is null
? task.TargetPosition.Value
: GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
ship.TargetPosition = undockTarget;
ship.State = ShipState.Undocking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{
if (station is not null)
{
ship.Position = GetShipDockedPosition(ship, station);
}
return "none";
}
ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > task.Threshold)
{
return "none";
}
if (station is not null)
{
station.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(station, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return "undocked";
}
internal static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
}

View File

@@ -1,313 +0,0 @@
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.InfrastructureSimulationService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed partial class ShipTaskExecutionService
{
private const float WarpEngageDistanceKilometers = 250_000f;
private static float GetLocalTravelSpeed(ShipRuntime ship) =>
SimulationUnits.MetersPerSecondToKilometersPerSecond(ship.Definition.Speed);
private static float GetWarpTravelSpeed(ShipRuntime ship) =>
SimulationUnits.AuPerSecondToKilometersPerSecond(ship.Definition.WarpSpeed);
private static Vector3 ResolveSystemGalaxyPosition(SimulationWorld world, string systemId) =>
world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, systemId, StringComparison.Ordinal))?.Position
?? Vector3.Zero;
internal string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return task.Kind switch
{
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
_ => UpdateIdle(ship, world, deltaSeconds),
};
}
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
// Resolve live position each frame — entities like stations orbit celestials and move every tick
var targetPosition = ResolveCurrentTargetPosition(world, task);
var targetCelestial = ResolveTravelTargetCelestial(world, task, targetPosition);
var distance = ship.Position.DistanceTo(targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != task.TargetSystemId)
{
if (!HasShipCapabilities(ship.Definition, "ftl"))
{
ship.State = ShipState.Idle;
return "none";
}
var destinationEntryCelestial = ResolveSystemEntryCelestial(world, task.TargetSystemId);
var destinationEntryPosition = destinationEntryCelestial?.Position ?? Vector3.Zero;
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryCelestial);
}
var currentCelestial = ResolveCurrentCelestial(world, ship);
if (targetCelestial is not null && currentCelestial is not null && !string.Equals(currentCelestial.Id, targetCelestial.Id, StringComparison.Ordinal))
{
if (!HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
if (targetCelestial is not null
&& distance > WarpEngageDistanceKilometers
&& HasShipCapabilities(ship.Definition, "warp"))
{
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetCelestial);
}
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetCelestial, task.Threshold);
}
private static Vector3 ResolveCurrentTargetPosition(SimulationWorld world, ControllerTaskRuntime task)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is not null)
{
return station.Position;
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial.Position;
}
}
return task.TargetPosition!.Value;
}
private static CelestialRuntime? ResolveTravelTargetCelestial(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station?.CelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == station.CelestialId);
}
var celestial = world.Celestials.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (celestial is not null)
{
return celestial;
}
}
return world.Celestials
.Where(candidate => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveCurrentCelestial(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentCelestialId is not null)
{
return world.Celestials.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentCelestialId);
}
return world.Celestials
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private static CelestialRuntime? ResolveSystemEntryCelestial(SimulationWorld world, string systemId) =>
world.Celestials.FirstOrDefault(candidate =>
candidate.SystemId == systemId &&
candidate.Kind == SpatialNodeKind.Star);
private string UpdateLocalTravel(
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
string targetSystemId,
Vector3 targetPosition,
CelestialRuntime? targetCelestial,
float threshold)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
if (distance <= threshold)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = ship.Position;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
ship.ActionTimer = 0f;
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, GetLocalTravelSpeed(ship) * deltaSeconds);
return "none";
}
private string UpdateWarpTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, Vector3 targetPosition, CelestialRuntime targetCelestial)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetCelestial.Id)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.Warp,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = targetCelestial.Id,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = targetCelestial.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != ShipState.Warping)
{
if (ship.State != ShipState.SpoolingWarp)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{
return "none";
}
ship.State = ShipState.Warping;
}
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.Celestials.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, GetWarpTravelSpeed(ship) * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 18f
? CompleteTransitArrival(ship, targetCelestial.SystemId, targetPosition, targetCelestial)
: "none";
}
private string UpdateFtlTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
var destinationNodeId = targetCelestial?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentCelestialId,
DestinationNodeId = destinationNodeId,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit;
ship.SpatialState.CurrentCelestialId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != ShipState.Ftl)
{
if (ship.State != ShipState.SpoolingFtl)
{
ship.ActionTimer = 0f;
}
ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{
return "none";
}
ship.State = ShipState.Ftl;
}
var originSystemPosition = ResolveSystemGalaxyPosition(world, ship.SystemId);
var destinationSystemPosition = ResolveSystemGalaxyPosition(world, targetSystemId);
var totalDistance = MathF.Max(0.001f, originSystemPosition.DistanceTo(destinationSystemPosition));
transit.Progress = MathF.Min(1f, transit.Progress + ((ship.Definition.FtlSpeed * deltaSeconds) / totalDistance));
return transit.Progress >= 0.999f
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetCelestial)
: "none";
}
private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "arrived";
}
private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, CelestialRuntime? targetCelestial)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentCelestialId = targetCelestial?.Id;
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
ship.State = ShipState.Arriving;
return "none";
}
}

View File

@@ -1,130 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.ShipControlService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class StationLifecycleService
{
private const float WaterConsumptionPerWorkerPerSecond = 0.004f;
private const float PopulationGrowthPerSecond = 0.012f;
private const float PopulationAttritionPerSecond = 0.018f;
private readonly StationSimulationService _stationSimulation;
internal StationLifecycleService(StationSimulationService stationSimulation)
{
_stationSimulation = stationSimulation;
}
internal void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var station in world.Stations)
{
UpdateStationPopulation(station, deltaSeconds, events);
_stationSimulation.ReviewStationMarketOrders(world, station);
_stationSimulation.RunStationProduction(world, station, deltaSeconds, events);
factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population;
}
foreach (var faction in world.Factions)
{
faction.PopulationTotal = GetInventoryAmount(factionPopulation, faction.Id);
}
}
private void UpdateStationPopulation(StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds;
var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater);
var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater;
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
if (waterSatisfied)
{
if (habitatModules > 0 && station.Population < station.PopulationCapacity)
{
station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds));
}
}
else if (station.Population > 0f)
{
var previous = station.Population;
station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds));
if (MathF.Floor(previous) > MathF.Floor(station.Population))
{
events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Label} lost population due to support shortages.", DateTimeOffset.UtcNow));
}
}
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
internal static float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
{
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
{
return 0f;
}
var spawnPosition = new Vector3(station.Position.X + GetStationRadius(world, station) + 32f, station.Position.Y, station.Position.Z);
var ship = new ShipRuntime
{
Id = $"ship-{world.Ships.Count + 1}",
SystemId = station.SystemId,
Definition = definition,
FactionId = station.FactionId,
Position = spawnPosition,
TargetPosition = spawnPosition,
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold),
Health = definition.MaxHealth,
};
world.Ships.Add(ship);
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
{
faction.ShipsBuilt += 1;
}
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
return 1f;
}
private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
{
CurrentSystemId = station.SystemId,
SpaceLayer = SpaceLayerKinds.LocalSpace,
CurrentCelestialId = station.CelestialId,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKinds.LocalFlight,
};
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
{
if (!string.Equals(definition.Kind, "military", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime { Kind = "idle" };
}
var patrolRadius = station.Radius + 90f;
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints =
[
new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius),
new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius),
],
};
}
}

View File

@@ -1,354 +0,0 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.Systems.CommanderPlanningService;
using static SpaceGame.Api.Simulation.Support.SimulationRuntimeSupport;
namespace SpaceGame.Api.Simulation.Systems;
internal sealed class StationSimulationService
{
internal const int StrategicControlTargetSystems = 5;
internal void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
{
if (station.CommanderId is null)
{
return;
}
var desiredOrders = new List<DesiredMarketOrder>();
var waterReserve = MathF.Max(30f, station.Population * 3f);
var refinedReserve = HasStationModules(station, "module_gen_prod_hullparts_01") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
var shipPartsReserve = HasStationModules(station, "module_gen_prod_hullparts_01")
&& !HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
&& FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships")
? 90f
: 0f;
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "refinedmetals", refinedReserve, valuationBase: 1.15f);
AddDemandOrder(desiredOrders, station, "hullparts", shipPartsReserve, valuationBase: 1.3f);
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
AddSupplyOrder(desiredOrders, station, "refinedmetals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
ReconcileStationMarketOrders(world, station, desiredOrders);
}
internal void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
foreach (var laneKey in GetStationProductionLanes(world, station))
{
var recipe = SelectProductionRecipe(world, station, laneKey);
if (recipe is null)
{
station.ProductionLaneTimers[laneKey] = 0f;
continue;
}
var throughput = GetStationProductionThroughput(world, station, recipe);
var produced = 0f;
station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput);
while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe))
{
station.ProductionLaneTimers[laneKey] -= recipe.Duration;
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
if (recipe.ShipOutputId is not null)
{
produced += StationLifecycleService.CompleteShipRecipe(world, station, recipe, events);
continue;
}
foreach (var output in recipe.Outputs)
{
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
}
}
if (produced <= 0.01f)
{
continue;
}
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
if (faction is not null)
{
faction.GoodsProduced += produced;
}
}
}
internal static IEnumerable<string> GetStationProductionLanes(SimulationWorld world, StationRuntime station)
{
foreach (var moduleId in station.InstalledModules.Distinct(StringComparer.Ordinal))
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var def) || string.IsNullOrEmpty(def.ProductionMode))
{
continue;
}
if (string.Equals(def.ProductionMode, "commanded", StringComparison.Ordinal) && station.CommanderId is null)
{
continue;
}
yield return moduleId;
}
}
internal static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
internal static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
world.Recipes.Values
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(world, recipe), laneKey, StringComparison.Ordinal))
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
private static string? GetStationProductionLaneKey(SimulationWorld world, RecipeDefinition recipe) =>
recipe.RequiredModules.FirstOrDefault(moduleId =>
world.ModuleDefinitions.TryGetValue(moduleId, out var def) && !string.IsNullOrEmpty(def.ProductionMode));
private static float GetStationProductionThroughput(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var laneModuleId = GetStationProductionLaneKey(world, recipe);
if (laneModuleId is null)
{
return 1f;
}
return Math.Max(1, CountModules(station.InstalledModules, laneModuleId));
}
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var priority = (float)recipe.Priority;
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure);
return priority;
}
private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure)
{
var outputItemIds = recipe.Outputs
.Select(output => output.ItemId)
.ToHashSet(StringComparer.Ordinal);
if (outputItemIds.Contains("hullparts"))
{
return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
? -140f * MathF.Max(expansionPressure, fleetPressure)
: 280f * MathF.Max(expansionPressure, fleetPressure);
}
if (outputItemIds.Contains("refinedmetals"))
{
return 180f * expansionPressure;
}
if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"]))
{
return 170f * expansionPressure;
}
if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"]))
{
return 160f * expansionPressure;
}
return recipe.Id switch
{
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
=> 220f * MathF.Max(expansionPressure, fleetPressure),
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
"ammo-fabrication" => -80f * expansionPressure,
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
=> -120f * expansionPressure,
_ => 0f,
};
}
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
{
var categoryMatch = string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|| string.Equals(recipe.FacilityCategory, "farm", StringComparison.Ordinal)
|| string.Equals(recipe.FacilityCategory, station.Category, StringComparison.Ordinal);
return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
}
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
if (recipe.ShipOutputId is not null)
{
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition))
{
return false;
}
if (!string.Equals(shipDefinition.Kind, "military", StringComparison.Ordinal)
|| !FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships"))
{
return false;
}
}
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
{
return false;
}
return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount));
}
private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return false;
}
var requiredModule = GetStorageRequirement(itemDefinition.CargoKind);
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
{
return false;
}
var capacity = GetStationStorageCapacity(station, itemDefinition.CargoKind);
if (capacity <= 0.01f)
{
return false;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.CargoKind == itemDefinition.CargoKind)
.Sum(entry => entry.Value);
return used + amount <= capacity + 0.001f;
}
private static bool HasRefineryCapability(StationRuntime station) =>
HasStationModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01");
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
{
var current = GetInventoryAmount(station.Inventory, itemId);
if (current >= targetAmount - 0.01f)
{
return;
}
var deficit = targetAmount - current;
var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount);
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null));
}
private static void AddSupplyOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase)
{
var current = GetInventoryAmount(station.Inventory, itemId);
if (current <= triggerAmount + 0.01f)
{
return;
}
var surplus = current - reserveFloor;
if (surplus <= 0.01f)
{
return;
}
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor));
}
private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
{
var existingOrders = world.MarketOrders
.Where(order => order.StationId == station.Id && order.ConstructionSiteId is null)
.ToList();
foreach (var desired in desiredOrders)
{
var order = existingOrders.FirstOrDefault(candidate =>
candidate.Kind == desired.Kind &&
candidate.ItemId == desired.ItemId &&
candidate.ConstructionSiteId is null);
if (order is null)
{
order = new MarketOrderRuntime
{
Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}",
FactionId = station.FactionId,
StationId = station.Id,
Kind = desired.Kind,
ItemId = desired.ItemId,
Amount = desired.Amount,
RemainingAmount = desired.Amount,
Valuation = desired.Valuation,
ReserveThreshold = desired.ReserveThreshold,
State = MarketOrderStateKinds.Open,
};
world.MarketOrders.Add(order);
station.MarketOrderIds.Add(order.Id);
existingOrders.Add(order);
continue;
}
order.RemainingAmount = desired.Amount;
order.Valuation = desired.Valuation;
order.ReserveThreshold = desired.ReserveThreshold;
order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open;
}
foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId)))
{
order.RemainingAmount = 0f;
order.State = MarketOrderStateKinds.Cancelled;
}
}
internal static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
{
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
var deficit = Math.Max(0, targetSystems - controlledSystems);
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
}
internal static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
{
return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id));
}
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{
var totalLagrangePoints = world.Celestials.Count(node =>
node.SystemId == systemId &&
node.Kind == SpatialNodeKind.LagrangePoint);
if (totalLagrangePoints == 0)
{
return false;
}
var ownedLocations = world.Claims.Count(claim =>
claim.SystemId == systemId &&
claim.FactionId == factionId &&
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active);
return ownedLocations > (totalLagrangePoints / 2f);
}
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
}

View File

@@ -1,7 +0,0 @@
namespace SpaceGame.Api.Simulation;
public sealed class WorldGenerationOptions
{
public int TargetSystemCount { get; init; } = 160;
}

View File

@@ -1,278 +0,0 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Contracts;
using SpaceGame.Api.Simulation.Engine;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation;
public sealed class WorldService(
IWebHostEnvironment environment,
IOptions<WorldGenerationOptions> worldGenerationOptions,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{
private const int DeltaHistoryLimit = 256;
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
private long _sequence;
public WorldSnapshot GetSnapshot()
{
lock (_sync)
{
return _engine.BuildSnapshot(_world, _sequence);
}
}
public (long Sequence, DateTimeOffset GeneratedAtUtc) GetStatus()
{
lock (_sync)
{
return (_sequence, _world.GeneratedAtUtc);
}
}
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)
{
_world = _loader.Load();
_sequence += 1;
_history.Clear();
var resetDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
DateTimeOffset.UtcNow,
true,
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],
[],
[],
[],
[],
[],
[],
[],
[],
[]);
_history.Enqueue(resetDelta);
foreach (var subscriber in _subscribers.Values.ToList())
{
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope));
}
return _engine.BuildSnapshot(_world, _sequence);
}
}
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;
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 : [],
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);
}