Refactor backend into domain-first slices
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace SpaceGame.Api.Simulation;
|
||||
|
||||
public sealed class OrbitalSimulationOptions
|
||||
{
|
||||
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace SpaceGame.Api.Simulation;
|
||||
|
||||
public sealed class WorldGenerationOptions
|
||||
{
|
||||
public int TargetSystemCount { get; init; } = 160;
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user