3145 lines
108 KiB
C#
3145 lines
108 KiB
C#
using SpaceGame.Simulation.Api.Data;
|
|
using SpaceGame.Simulation.Api.Contracts;
|
|
|
|
namespace SpaceGame.Simulation.Api.Simulation;
|
|
|
|
public sealed class SimulationEngine
|
|
{
|
|
private const float ShipFuelToEnergyRatio = 12f;
|
|
private const float StationFuelToEnergyRatio = 18f;
|
|
private const float CapacitorEnergyPerModule = 120f;
|
|
private const float StationEnergyPerPowerCore = 480f;
|
|
private const float ShipFuelPerReactor = 100f;
|
|
private const float StationFuelPerTank = 500f;
|
|
private const float WaterConsumptionPerWorkerPerSecond = 0.004f;
|
|
private const float PopulationGrowthPerSecond = 0.012f;
|
|
private const float PopulationAttritionPerSecond = 0.018f;
|
|
|
|
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
|
|
{
|
|
var events = new List<SimulationEventRecord>();
|
|
var nowUtc = DateTimeOffset.UtcNow;
|
|
|
|
UpdateOrbitalState(world, nowUtc);
|
|
|
|
UpdateClaims(world, events);
|
|
UpdateConstructionSites(world, events);
|
|
UpdateStationPower(world, deltaSeconds, events);
|
|
UpdateStations(world, deltaSeconds, events);
|
|
|
|
foreach (var ship in world.Ships)
|
|
{
|
|
var previousPosition = ship.Position;
|
|
var previousState = ship.State;
|
|
var previousBehavior = ship.DefaultBehavior.Kind;
|
|
var previousTask = ship.ControllerTask.Kind;
|
|
|
|
UpdateShipPower(ship, world, deltaSeconds, events);
|
|
RefreshControlLayers(ship, world);
|
|
PlanControllerTask(ship, world);
|
|
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
|
AdvanceControlState(ship, world, controllerEvent);
|
|
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
|
|
TrackHistory(ship);
|
|
|
|
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
|
|
}
|
|
|
|
SyncSpatialState(world);
|
|
world.GeneratedAtUtc = nowUtc;
|
|
|
|
return new WorldDelta(
|
|
sequence,
|
|
world.TickIntervalMs,
|
|
world.GeneratedAtUtc,
|
|
false,
|
|
events,
|
|
BuildSpatialNodeDeltas(world),
|
|
BuildLocalBubbleDeltas(world),
|
|
BuildNodeDeltas(world),
|
|
BuildStationDeltas(world),
|
|
BuildClaimDeltas(world),
|
|
BuildConstructionSiteDeltas(world),
|
|
BuildMarketOrderDeltas(world),
|
|
BuildPolicyDeltas(world),
|
|
BuildShipDeltas(world),
|
|
BuildFactionDeltas(world));
|
|
}
|
|
|
|
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
|
|
{
|
|
PrimeDeltaBaseline(world);
|
|
|
|
return new WorldSnapshot(
|
|
world.Label,
|
|
world.Seed,
|
|
sequence,
|
|
world.TickIntervalMs,
|
|
world.GeneratedAtUtc,
|
|
world.Systems.Select((system) => new SystemSnapshot(
|
|
system.Definition.Id,
|
|
system.Definition.Label,
|
|
ToDto(system.Position),
|
|
system.Definition.StarKind,
|
|
system.Definition.StarCount,
|
|
system.Definition.StarColor,
|
|
system.Definition.StarSize,
|
|
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
|
|
planet.Label,
|
|
planet.PlanetType,
|
|
planet.Shape,
|
|
planet.MoonCount,
|
|
planet.OrbitRadius,
|
|
planet.OrbitSpeed,
|
|
planet.OrbitEccentricity,
|
|
planet.OrbitInclination,
|
|
planet.OrbitLongitudeOfAscendingNode,
|
|
planet.OrbitArgumentOfPeriapsis,
|
|
planet.OrbitPhaseAtEpoch,
|
|
planet.Size,
|
|
planet.Color,
|
|
planet.HasRing)).ToList())).ToList(),
|
|
world.SpatialNodes.Select(ToSpatialNodeDelta).Select((node) => new SpatialNodeSnapshot(
|
|
node.Id,
|
|
node.SystemId,
|
|
node.Kind,
|
|
node.LocalPosition,
|
|
node.BubbleId,
|
|
node.ParentNodeId,
|
|
node.OccupyingStructureId,
|
|
node.OrbitReferenceId)).ToList(),
|
|
world.LocalBubbles.Select(ToLocalBubbleDelta).Select((bubble) => new LocalBubbleSnapshot(
|
|
bubble.Id,
|
|
bubble.NodeId,
|
|
bubble.SystemId,
|
|
bubble.Radius,
|
|
bubble.OccupantShipIds,
|
|
bubble.OccupantStationIds,
|
|
bubble.OccupantClaimIds,
|
|
bubble.OccupantConstructionSiteIds)).ToList(),
|
|
world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot(
|
|
node.Id,
|
|
node.SystemId,
|
|
node.LocalPosition,
|
|
node.SourceKind,
|
|
node.OreRemaining,
|
|
node.MaxOre,
|
|
node.ItemId)).ToList(),
|
|
world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot(
|
|
station.Id,
|
|
station.Label,
|
|
station.Category,
|
|
station.SystemId,
|
|
station.LocalPosition,
|
|
station.NodeId,
|
|
station.BubbleId,
|
|
station.AnchorNodeId,
|
|
station.Color,
|
|
station.DockedShips,
|
|
station.DockingPads,
|
|
station.EnergyStored,
|
|
station.Inventory,
|
|
station.FactionId,
|
|
station.CommanderId,
|
|
station.PolicySetId,
|
|
station.Population,
|
|
station.PopulationCapacity,
|
|
station.WorkforceRequired,
|
|
station.WorkforceEffectiveRatio,
|
|
station.InstalledModules,
|
|
station.MarketOrderIds)).ToList(),
|
|
world.Claims.Select(ToClaimDelta).Select((claim) => new ClaimSnapshot(
|
|
claim.Id,
|
|
claim.FactionId,
|
|
claim.SystemId,
|
|
claim.NodeId,
|
|
claim.BubbleId,
|
|
claim.State,
|
|
claim.Health,
|
|
claim.PlacedAtUtc,
|
|
claim.ActivatesAtUtc)).ToList(),
|
|
world.ConstructionSites.Select(ToConstructionSiteDelta).Select((site) => new ConstructionSiteSnapshot(
|
|
site.Id,
|
|
site.FactionId,
|
|
site.SystemId,
|
|
site.NodeId,
|
|
site.BubbleId,
|
|
site.TargetKind,
|
|
site.TargetDefinitionId,
|
|
site.BlueprintId,
|
|
site.ClaimId,
|
|
site.StationId,
|
|
site.State,
|
|
site.Progress,
|
|
site.Inventory,
|
|
site.RequiredItems,
|
|
site.DeliveredItems,
|
|
site.AssignedConstructorShipIds,
|
|
site.MarketOrderIds)).ToList(),
|
|
world.MarketOrders.Select(ToMarketOrderDelta).Select((order) => new MarketOrderSnapshot(
|
|
order.Id,
|
|
order.FactionId,
|
|
order.StationId,
|
|
order.ConstructionSiteId,
|
|
order.Kind,
|
|
order.ItemId,
|
|
order.Amount,
|
|
order.RemainingAmount,
|
|
order.Valuation,
|
|
order.ReserveThreshold,
|
|
order.PolicySetId,
|
|
order.State)).ToList(),
|
|
world.Policies.Select(ToPolicySetDelta).Select((policy) => new PolicySetSnapshot(
|
|
policy.Id,
|
|
policy.OwnerKind,
|
|
policy.OwnerId,
|
|
policy.TradeAccessPolicy,
|
|
policy.DockingAccessPolicy,
|
|
policy.ConstructionAccessPolicy,
|
|
policy.OperationalRangePolicy)).ToList(),
|
|
world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot(
|
|
ship.Id,
|
|
ship.Label,
|
|
ship.Role,
|
|
ship.ShipClass,
|
|
ship.SystemId,
|
|
ship.LocalPosition,
|
|
ship.LocalVelocity,
|
|
ship.TargetLocalPosition,
|
|
ship.State,
|
|
ship.OrderKind,
|
|
ship.DefaultBehaviorKind,
|
|
ship.ControllerTaskKind,
|
|
ship.NodeId,
|
|
ship.BubbleId,
|
|
ship.DockedStationId,
|
|
ship.CommanderId,
|
|
ship.PolicySetId,
|
|
ship.CargoCapacity,
|
|
ship.WorkerPopulation,
|
|
ship.EnergyStored,
|
|
ship.Inventory,
|
|
ship.FactionId,
|
|
ship.Health,
|
|
ship.History,
|
|
ship.SpatialState)).ToList(),
|
|
world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot(
|
|
faction.Id,
|
|
faction.Label,
|
|
faction.Color,
|
|
faction.Credits,
|
|
faction.PopulationTotal,
|
|
faction.OreMined,
|
|
faction.GoodsProduced,
|
|
faction.ShipsBuilt,
|
|
faction.ShipsLost,
|
|
faction.DefaultPolicySetId)).ToList());
|
|
}
|
|
|
|
public void PrimeDeltaBaseline(SimulationWorld world)
|
|
{
|
|
foreach (var node in world.Nodes)
|
|
{
|
|
node.LastDeltaSignature = BuildNodeSignature(node);
|
|
}
|
|
|
|
foreach (var node in world.SpatialNodes)
|
|
{
|
|
node.LastDeltaSignature = BuildSpatialNodeSignature(node);
|
|
}
|
|
|
|
foreach (var bubble in world.LocalBubbles)
|
|
{
|
|
bubble.LastDeltaSignature = BuildLocalBubbleSignature(bubble);
|
|
}
|
|
|
|
foreach (var station in world.Stations)
|
|
{
|
|
station.LastDeltaSignature = BuildStationSignature(station);
|
|
}
|
|
|
|
foreach (var claim in world.Claims)
|
|
{
|
|
claim.LastDeltaSignature = BuildClaimSignature(claim);
|
|
}
|
|
|
|
foreach (var site in world.ConstructionSites)
|
|
{
|
|
site.LastDeltaSignature = BuildConstructionSiteSignature(site);
|
|
}
|
|
|
|
foreach (var order in world.MarketOrders)
|
|
{
|
|
order.LastDeltaSignature = BuildMarketOrderSignature(order);
|
|
}
|
|
|
|
foreach (var policy in world.Policies)
|
|
{
|
|
policy.LastDeltaSignature = BuildPolicySignature(policy);
|
|
}
|
|
|
|
foreach (var ship in world.Ships)
|
|
{
|
|
ship.LastDeltaSignature = BuildShipSignature(ship);
|
|
}
|
|
|
|
foreach (var faction in world.Factions)
|
|
{
|
|
faction.LastDeltaSignature = BuildFactionSignature(faction);
|
|
}
|
|
}
|
|
|
|
private static IReadOnlyList<ResourceNodeDelta> BuildNodeDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<ResourceNodeDelta>();
|
|
foreach (var node in world.Nodes)
|
|
{
|
|
var signature = BuildNodeSignature(node);
|
|
if (signature == node.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
node.LastDeltaSignature = signature;
|
|
deltas.Add(ToNodeDelta(node));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<SpatialNodeDelta> BuildSpatialNodeDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<SpatialNodeDelta>();
|
|
foreach (var node in world.SpatialNodes)
|
|
{
|
|
var signature = BuildSpatialNodeSignature(node);
|
|
if (signature == node.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
node.LastDeltaSignature = signature;
|
|
deltas.Add(ToSpatialNodeDelta(node));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<LocalBubbleDelta> BuildLocalBubbleDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<LocalBubbleDelta>();
|
|
foreach (var bubble in world.LocalBubbles)
|
|
{
|
|
var signature = BuildLocalBubbleSignature(bubble);
|
|
if (signature == bubble.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
bubble.LastDeltaSignature = signature;
|
|
deltas.Add(ToLocalBubbleDelta(bubble));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<StationDelta> BuildStationDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<StationDelta>();
|
|
foreach (var station in world.Stations)
|
|
{
|
|
var signature = BuildStationSignature(station);
|
|
if (signature == station.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
station.LastDeltaSignature = signature;
|
|
deltas.Add(ToStationDelta(station));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<ClaimDelta> BuildClaimDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<ClaimDelta>();
|
|
foreach (var claim in world.Claims)
|
|
{
|
|
var signature = BuildClaimSignature(claim);
|
|
if (signature == claim.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
claim.LastDeltaSignature = signature;
|
|
deltas.Add(ToClaimDelta(claim));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<ConstructionSiteDelta> BuildConstructionSiteDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<ConstructionSiteDelta>();
|
|
foreach (var site in world.ConstructionSites)
|
|
{
|
|
var signature = BuildConstructionSiteSignature(site);
|
|
if (signature == site.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
site.LastDeltaSignature = signature;
|
|
deltas.Add(ToConstructionSiteDelta(site));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<MarketOrderDelta> BuildMarketOrderDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<MarketOrderDelta>();
|
|
foreach (var order in world.MarketOrders)
|
|
{
|
|
var signature = BuildMarketOrderSignature(order);
|
|
if (signature == order.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
order.LastDeltaSignature = signature;
|
|
deltas.Add(ToMarketOrderDelta(order));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<PolicySetDelta> BuildPolicyDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<PolicySetDelta>();
|
|
foreach (var policy in world.Policies)
|
|
{
|
|
var signature = BuildPolicySignature(policy);
|
|
if (signature == policy.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
policy.LastDeltaSignature = signature;
|
|
deltas.Add(ToPolicySetDelta(policy));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<ShipDelta>();
|
|
foreach (var ship in world.Ships)
|
|
{
|
|
var signature = BuildShipSignature(ship);
|
|
if (signature == ship.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ship.LastDeltaSignature = signature;
|
|
deltas.Add(ToShipDelta(ship));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static IReadOnlyList<FactionDelta> BuildFactionDeltas(SimulationWorld world)
|
|
{
|
|
var deltas = new List<FactionDelta>();
|
|
foreach (var faction in world.Factions)
|
|
{
|
|
var signature = BuildFactionSignature(faction);
|
|
if (signature == faction.LastDeltaSignature)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
faction.LastDeltaSignature = signature;
|
|
deltas.Add(ToFactionDelta(faction));
|
|
}
|
|
|
|
return deltas;
|
|
}
|
|
|
|
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
|
|
$"{node.SystemId}|{node.OreRemaining:0.###}";
|
|
|
|
private static string BuildSpatialNodeSignature(NodeRuntime node) =>
|
|
$"{node.SystemId}|{node.Kind}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.BubbleId}|{node.ParentNodeId}|{node.OccupyingStructureId}|{node.OrbitReferenceId}";
|
|
|
|
private static string BuildLocalBubbleSignature(LocalBubbleRuntime bubble) =>
|
|
$"{bubble.SystemId}|{bubble.NodeId}|{bubble.Radius:0.###}|{string.Join(",", bubble.OccupantShipIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantStationIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantClaimIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantConstructionSiteIds.OrderBy((id) => id, StringComparer.Ordinal))}";
|
|
|
|
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 = 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(PlanetDefinition planet, int moonIndex, float timeSeconds)
|
|
{
|
|
var orbitRadius = ComputeMoonOrbitRadius(planet, moonIndex);
|
|
var speed = ComputeMoonOrbitSpeed(planet, moonIndex);
|
|
var phase = HashUnit($"{planet.Label}:{moonIndex}:phase") * MathF.PI * 2f;
|
|
var inclination = DegreesToRadians((HashUnit($"{planet.Label}:{moonIndex}:inclination") - 0.5f) * 28f);
|
|
var ascendingNode = DegreesToRadians(HashUnit($"{planet.Label}:{moonIndex}:node") * 360f);
|
|
var angle = phase + (timeSeconds * speed);
|
|
|
|
var local = new Vector3(
|
|
MathF.Cos(angle) * orbitRadius,
|
|
0f,
|
|
MathF.Sin(angle) * orbitRadius);
|
|
local = RotateAroundX(local, inclination);
|
|
local = RotateAroundY(local, ascendingNode);
|
|
return local;
|
|
}
|
|
|
|
private static float ComputeMoonOrbitRadius(PlanetDefinition planet, int moonIndex)
|
|
{
|
|
var spacing = planet.Size * 1.4f;
|
|
var variance = HashUnit($"{planet.Label}:{moonIndex}:radius") * planet.Size * 0.9f;
|
|
return (planet.Size * 1.8f) + (moonIndex * spacing) + variance;
|
|
}
|
|
|
|
private static float ComputeMoonOrbitSpeed(PlanetDefinition planet, int moonIndex)
|
|
{
|
|
var radius = ComputeMoonOrbitRadius(planet, moonIndex);
|
|
return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f);
|
|
}
|
|
|
|
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
|
|
{
|
|
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
|
|
var tangential = new Vector3(-radial.Z, 0f, radial.X);
|
|
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f));
|
|
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, -orbitRadius));
|
|
yield return new LagrangePointPlacement(
|
|
"L4",
|
|
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
|
|
yield return new LagrangePointPlacement(
|
|
"L5",
|
|
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc)
|
|
{
|
|
var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f);
|
|
var spatialNodesById = world.SpatialNodes.ToDictionary((node) => node.Id, StringComparer.Ordinal);
|
|
|
|
foreach (var system in world.Systems)
|
|
{
|
|
var starNodeId = $"node-{system.Definition.Id}-star";
|
|
if (spatialNodesById.TryGetValue(starNodeId, out var starNode))
|
|
{
|
|
starNode.Position = Vector3.Zero;
|
|
}
|
|
|
|
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 (!spatialNodesById.TryGetValue(planetNodeId, out var planetNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
|
|
planetNode.Position = planetPosition;
|
|
|
|
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
|
|
{
|
|
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
|
|
if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode))
|
|
{
|
|
lagrangeNode.Position = lagrange.Position;
|
|
}
|
|
}
|
|
|
|
var moonCount = planet.MoonCount;
|
|
for (var moonIndex = 0; moonIndex < moonCount; moonIndex += 1)
|
|
{
|
|
var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
|
|
if (!spatialNodesById.TryGetValue(moonId, out var moonNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet, moonIndex, worldTimeSeconds));
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var station in world.Stations)
|
|
{
|
|
if (station.AnchorNodeId is null || !spatialNodesById.TryGetValue(station.AnchorNodeId, out var anchorNode))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
station.Position = anchorNode.Position;
|
|
if (station.NodeId is not null && spatialNodesById.TryGetValue(station.NodeId, out var stationNode))
|
|
{
|
|
stationNode.Position = station.Position;
|
|
}
|
|
}
|
|
|
|
foreach (var ship in world.Ships)
|
|
{
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
private static void SyncSpatialState(SimulationWorld world)
|
|
{
|
|
foreach (var bubble in world.LocalBubbles)
|
|
{
|
|
bubble.OccupantShipIds.Clear();
|
|
}
|
|
|
|
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.CurrentNodeId = null;
|
|
ship.SpatialState.CurrentBubbleId = null;
|
|
continue;
|
|
}
|
|
|
|
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
|
|
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
|
|
var nearestNode = world.SpatialNodes
|
|
.Where((candidate) => candidate.SystemId == ship.SystemId)
|
|
.OrderBy((candidate) => candidate.Position.DistanceTo(ship.Position))
|
|
.FirstOrDefault();
|
|
ship.SpatialState.CurrentNodeId = nearestNode?.Id;
|
|
ship.SpatialState.CurrentBubbleId = nearestNode?.BubbleId;
|
|
|
|
if (nearestNode is not null)
|
|
{
|
|
var nearestBubble = world.LocalBubbles.FirstOrDefault((candidate) => candidate.Id == nearestNode.BubbleId);
|
|
nearestBubble?.OccupantShipIds.Add(ship.Id);
|
|
}
|
|
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
if (station?.BubbleId is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ship.SpatialState.CurrentNodeId = station.NodeId;
|
|
ship.SpatialState.CurrentBubbleId = station.BubbleId;
|
|
var bubble = world.LocalBubbles.FirstOrDefault((candidate) => candidate.Id == station.BubbleId);
|
|
bubble?.OccupantShipIds.Add(ship.Id);
|
|
}
|
|
}
|
|
|
|
private static string BuildStationSignature(StationRuntime station) =>
|
|
$"{station.SystemId}|{station.NodeId}|{station.BubbleId}|{station.AnchorNodeId}|{station.CommanderId}|{station.PolicySetId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{station.Population:0.###}|{station.PopulationCapacity:0.###}|{station.WorkforceRequired:0.###}|{station.WorkforceEffectiveRatio:0.###}|{string.Join(",", station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal))}|{string.Join(",", station.MarketOrderIds.OrderBy((orderId) => orderId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}";
|
|
|
|
private static string BuildClaimSignature(ClaimRuntime claim) =>
|
|
$"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
|
|
|
|
private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) =>
|
|
$"{site.FactionId}|{site.SystemId}|{site.NodeId}|{site.BubbleId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy((id) => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy((id) => id, StringComparer.Ordinal))}";
|
|
|
|
private static string BuildMarketOrderSignature(MarketOrderRuntime order) =>
|
|
$"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}";
|
|
|
|
private static string BuildPolicySignature(PolicySetRuntime policy) =>
|
|
$"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}";
|
|
|
|
private static string BuildShipSignature(ShipRuntime ship) =>
|
|
string.Join("|",
|
|
ship.SystemId,
|
|
ship.Position.X.ToString("0.###"),
|
|
ship.Position.Y.ToString("0.###"),
|
|
ship.Position.Z.ToString("0.###"),
|
|
ship.Velocity.X.ToString("0.###"),
|
|
ship.Velocity.Y.ToString("0.###"),
|
|
ship.Velocity.Z.ToString("0.###"),
|
|
ship.TargetPosition.X.ToString("0.###"),
|
|
ship.TargetPosition.Y.ToString("0.###"),
|
|
ship.TargetPosition.Z.ToString("0.###"),
|
|
ship.State,
|
|
ship.Order?.Kind ?? "none",
|
|
ship.DefaultBehavior.Kind,
|
|
ship.ControllerTask.Kind,
|
|
ship.SpatialState.CurrentNodeId ?? "none",
|
|
ship.SpatialState.CurrentBubbleId ?? "none",
|
|
ship.DockedStationId ?? "none",
|
|
ship.CommanderId ?? "none",
|
|
ship.PolicySetId ?? "none",
|
|
ship.WorkerPopulation.ToString("0.###"),
|
|
ship.SpatialState.SpaceLayer,
|
|
ship.SpatialState.CurrentNodeId ?? "none",
|
|
ship.SpatialState.CurrentBubbleId ?? "none",
|
|
ship.SpatialState.MovementRegime,
|
|
ship.SpatialState.DestinationNodeId ?? "none",
|
|
ship.SpatialState.Transit?.Regime ?? "none",
|
|
ship.SpatialState.Transit?.OriginNodeId ?? "none",
|
|
ship.SpatialState.Transit?.DestinationNodeId ?? "none",
|
|
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
|
|
GetShipCargoAmount(ship).ToString("0.###"),
|
|
GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"),
|
|
ship.EnergyStored.ToString("0.###"),
|
|
ship.Health.ToString("0.###"));
|
|
|
|
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
|
string.Join(",",
|
|
inventory
|
|
.Where((entry) => entry.Value > 0.001f)
|
|
.OrderBy((entry) => entry.Key, StringComparer.Ordinal)
|
|
.Select((entry) => $"{entry.Key}:{entry.Value:0.###}"));
|
|
|
|
private static string BuildFactionSignature(FactionRuntime faction) =>
|
|
$"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}";
|
|
|
|
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
|
|
node.Id,
|
|
node.SystemId,
|
|
ToDto(node.Position),
|
|
node.SourceKind,
|
|
node.OreRemaining,
|
|
node.MaxOre,
|
|
node.ItemId);
|
|
|
|
private static SpatialNodeDelta ToSpatialNodeDelta(NodeRuntime node) => new(
|
|
node.Id,
|
|
node.SystemId,
|
|
node.Kind,
|
|
ToDto(node.Position),
|
|
node.BubbleId,
|
|
node.ParentNodeId,
|
|
node.OccupyingStructureId,
|
|
node.OrbitReferenceId);
|
|
|
|
private static LocalBubbleDelta ToLocalBubbleDelta(LocalBubbleRuntime bubble) => new(
|
|
bubble.Id,
|
|
bubble.NodeId,
|
|
bubble.SystemId,
|
|
bubble.Radius,
|
|
bubble.OccupantShipIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(),
|
|
bubble.OccupantStationIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(),
|
|
bubble.OccupantClaimIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(),
|
|
bubble.OccupantConstructionSiteIds.OrderBy((id) => id, StringComparer.Ordinal).ToList());
|
|
|
|
private static StationDelta ToStationDelta(StationRuntime station) => new(
|
|
station.Id,
|
|
station.Definition.Label,
|
|
station.Definition.Category,
|
|
station.SystemId,
|
|
ToDto(station.Position),
|
|
station.NodeId,
|
|
station.BubbleId,
|
|
station.AnchorNodeId,
|
|
station.Definition.Color,
|
|
station.DockedShipIds.Count,
|
|
GetDockingPadCount(station),
|
|
station.EnergyStored,
|
|
ToInventoryEntries(station.Inventory),
|
|
station.FactionId,
|
|
station.CommanderId,
|
|
station.PolicySetId,
|
|
station.Population,
|
|
station.PopulationCapacity,
|
|
station.WorkforceRequired,
|
|
station.WorkforceEffectiveRatio,
|
|
station.InstalledModules.OrderBy((moduleId) => moduleId, StringComparer.Ordinal).ToList(),
|
|
station.MarketOrderIds.OrderBy((orderId) => orderId, StringComparer.Ordinal).ToList());
|
|
|
|
private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new(
|
|
claim.Id,
|
|
claim.FactionId,
|
|
claim.SystemId,
|
|
claim.NodeId,
|
|
claim.BubbleId,
|
|
claim.State,
|
|
claim.Health,
|
|
claim.PlacedAtUtc,
|
|
claim.ActivatesAtUtc);
|
|
|
|
private static ConstructionSiteDelta ToConstructionSiteDelta(ConstructionSiteRuntime site) => new(
|
|
site.Id,
|
|
site.FactionId,
|
|
site.SystemId,
|
|
site.NodeId,
|
|
site.BubbleId,
|
|
site.TargetKind,
|
|
site.TargetDefinitionId,
|
|
site.BlueprintId,
|
|
site.ClaimId,
|
|
site.StationId,
|
|
site.State,
|
|
site.Progress,
|
|
ToInventoryEntries(site.Inventory),
|
|
ToInventoryEntries(site.RequiredItems),
|
|
ToInventoryEntries(site.DeliveredItems),
|
|
site.AssignedConstructorShipIds.OrderBy((id) => id, StringComparer.Ordinal).ToList(),
|
|
site.MarketOrderIds.OrderBy((id) => id, StringComparer.Ordinal).ToList());
|
|
|
|
private static MarketOrderDelta ToMarketOrderDelta(MarketOrderRuntime order) => new(
|
|
order.Id,
|
|
order.FactionId,
|
|
order.StationId,
|
|
order.ConstructionSiteId,
|
|
order.Kind,
|
|
order.ItemId,
|
|
order.Amount,
|
|
order.RemainingAmount,
|
|
order.Valuation,
|
|
order.ReserveThreshold,
|
|
order.PolicySetId,
|
|
order.State);
|
|
|
|
private static PolicySetDelta ToPolicySetDelta(PolicySetRuntime policy) => new(
|
|
policy.Id,
|
|
policy.OwnerKind,
|
|
policy.OwnerId,
|
|
policy.TradeAccessPolicy,
|
|
policy.DockingAccessPolicy,
|
|
policy.ConstructionAccessPolicy,
|
|
policy.OperationalRangePolicy);
|
|
|
|
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
|
|
ship.Id,
|
|
ship.Definition.Label,
|
|
ship.Definition.Role,
|
|
ship.Definition.ShipClass,
|
|
ship.SystemId,
|
|
ToDto(ship.Position),
|
|
ToDto(ship.Velocity),
|
|
ToDto(ship.TargetPosition),
|
|
ship.State,
|
|
ship.Order?.Kind,
|
|
ship.DefaultBehavior.Kind,
|
|
ship.ControllerTask.Kind,
|
|
ship.SpatialState.CurrentNodeId,
|
|
ship.SpatialState.CurrentBubbleId,
|
|
ship.DockedStationId,
|
|
ship.CommanderId,
|
|
ship.PolicySetId,
|
|
ship.Definition.CargoCapacity,
|
|
ship.WorkerPopulation,
|
|
ship.EnergyStored,
|
|
ToInventoryEntries(ship.Inventory),
|
|
ship.FactionId,
|
|
ship.Health,
|
|
ship.History.ToList(),
|
|
ToShipSpatialStateSnapshot(ship.SpatialState));
|
|
|
|
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
|
|
inventory
|
|
.Where((entry) => entry.Value > 0.001f)
|
|
.OrderBy((entry) => entry.Key, StringComparer.Ordinal)
|
|
.Select((entry) => new InventoryEntry(entry.Key, entry.Value))
|
|
.ToList();
|
|
|
|
private static FactionDelta ToFactionDelta(FactionRuntime faction) => new(
|
|
faction.Id,
|
|
faction.Label,
|
|
faction.Color,
|
|
faction.Credits,
|
|
faction.PopulationTotal,
|
|
faction.OreMined,
|
|
faction.GoodsProduced,
|
|
faction.ShipsBuilt,
|
|
faction.ShipsLost,
|
|
faction.DefaultPolicySetId);
|
|
|
|
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
|
|
state.SpaceLayer,
|
|
state.CurrentSystemId,
|
|
state.CurrentNodeId,
|
|
state.CurrentBubbleId,
|
|
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
|
|
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
|
|
state.MovementRegime,
|
|
state.DestinationNodeId,
|
|
state.Transit is null ? null : new ShipTransitSnapshot(
|
|
state.Transit.Regime,
|
|
state.Transit.OriginNodeId,
|
|
state.Transit.DestinationNodeId,
|
|
state.Transit.StartedAtUtc,
|
|
state.Transit.ArrivalDueAtUtc,
|
|
state.Transit.Progress));
|
|
|
|
private static void EmitShipStateEvents(
|
|
ShipRuntime ship,
|
|
string previousState,
|
|
string previousBehavior,
|
|
string 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} -> {ship.State}", 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} -> {ship.ControllerTask.Kind}", occurredAtUtc));
|
|
}
|
|
|
|
if (controllerEvent != "none")
|
|
{
|
|
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
|
|
}
|
|
}
|
|
|
|
private 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);
|
|
ReviewStationMarketOrders(world, station);
|
|
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.InstalledModules.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 hasPower = station.EnergyStored > 0.01f;
|
|
var habitatModules = CountModules(station.InstalledModules, "habitat-ring");
|
|
station.PopulationCapacity = 40f + (habitatModules * 220f);
|
|
|
|
if (waterSatisfied && hasPower)
|
|
{
|
|
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.Definition.Label} lost population due to support shortages.", DateTimeOffset.UtcNow));
|
|
}
|
|
}
|
|
|
|
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
|
|
}
|
|
|
|
private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
|
|
{
|
|
if (station.CommanderId is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var desiredOrders = new List<DesiredMarketOrder>();
|
|
var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f);
|
|
var waterReserve = MathF.Max(30f, station.Population * 3f);
|
|
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
|
|
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
|
|
var gasReserve = CanProcessFuel(station) ? 120f : 0f;
|
|
|
|
AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f);
|
|
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
|
|
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
|
|
AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f);
|
|
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
|
|
|
|
AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f);
|
|
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, "gas", gasReserve * 1.4f, reserveFloor: gasReserve, valuationBase: 0.72f);
|
|
AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
|
|
|
|
ReconcileStationMarketOrders(world, station, desiredOrders);
|
|
}
|
|
|
|
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
|
{
|
|
var recipe = SelectProductionRecipe(world, station);
|
|
if (recipe is null || station.EnergyStored <= 0.01f)
|
|
{
|
|
station.ProcessTimer = 0f;
|
|
return;
|
|
}
|
|
|
|
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
station.ProcessTimer = 0f;
|
|
return;
|
|
}
|
|
|
|
station.ProcessTimer += deltaSeconds * station.WorkforceEffectiveRatio;
|
|
if (station.ProcessTimer < recipe.Duration)
|
|
{
|
|
return;
|
|
}
|
|
|
|
station.ProcessTimer = 0f;
|
|
foreach (var input in recipe.Inputs)
|
|
{
|
|
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
|
|
}
|
|
|
|
var produced = 0f;
|
|
foreach (var output in recipe.Outputs)
|
|
{
|
|
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
|
|
}
|
|
|
|
if (produced <= 0.01f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
|
|
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
|
|
if (faction is not null)
|
|
{
|
|
faction.GoodsProduced += produced;
|
|
}
|
|
}
|
|
|
|
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station)
|
|
{
|
|
return world.Recipes.Values
|
|
.Where((recipe) => RecipeAppliesToStation(station, recipe))
|
|
.OrderByDescending((recipe) => recipe.Priority)
|
|
.FirstOrDefault((recipe) => CanRunRecipe(world, station, recipe));
|
|
}
|
|
|
|
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
|
{
|
|
var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal)
|
|
|| (string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
|
|
&& station.Definition.Category is "station" or "shipyard" or "defense" or "gate");
|
|
return categoryMatch && recipe.RequiredModules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
|
|
}
|
|
|
|
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
|
{
|
|
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.Storage);
|
|
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var used = station.Inventory
|
|
.Where((entry) => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == itemDefinition.Storage)
|
|
.Sum((entry) => entry.Value);
|
|
return used + amount <= capacity + 0.001f;
|
|
}
|
|
|
|
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)
|
|
{
|
|
if (desiredOrders.Any((desired) => desired.Kind == order.Kind && desired.ItemId == order.ItemId))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
order.RemainingAmount = 0f;
|
|
order.State = MarketOrderStateKinds.Cancelled;
|
|
}
|
|
}
|
|
|
|
private static bool HasRefineryCapability(StationRuntime station) =>
|
|
HasStationModules(station, "refinery-stack", "power-core", "bulk-bay");
|
|
|
|
private static bool CanProcessFuel(StationRuntime station) =>
|
|
HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank");
|
|
|
|
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
|
|
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
|
|
|
|
private static bool CanTransportWorkers(ShipRuntime ship) =>
|
|
CountModules(ship.Definition.Modules, "habitat-ring") > 0;
|
|
|
|
private static float GetWorkerTransportCapacity(ShipRuntime ship) =>
|
|
CountModules(ship.Definition.Modules, "habitat-ring") * 120f;
|
|
|
|
private static void UpdateStationPower(
|
|
SimulationWorld world,
|
|
float deltaSeconds,
|
|
ICollection<SimulationEventRecord> events)
|
|
{
|
|
foreach (var station in world.Stations)
|
|
{
|
|
var previousEnergy = station.EnergyStored;
|
|
GenerateStationEnergy(station, deltaSeconds);
|
|
|
|
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f)
|
|
{
|
|
events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void UpdateShipPower(
|
|
ShipRuntime ship,
|
|
SimulationWorld world,
|
|
float deltaSeconds,
|
|
ICollection<SimulationEventRecord> events)
|
|
{
|
|
var previousEnergy = ship.EnergyStored;
|
|
GenerateShipEnergy(ship, world, deltaSeconds);
|
|
|
|
if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f)
|
|
{
|
|
events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
|
|
}
|
|
}
|
|
|
|
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds)
|
|
{
|
|
var powerCores = CountModules(station.InstalledModules, "power-core");
|
|
var tanks = CountModules(station.InstalledModules, "liquid-tank");
|
|
if (powerCores <= 0 || tanks <= 0)
|
|
{
|
|
station.EnergyStored = 0f;
|
|
station.Inventory.Remove("fuel");
|
|
return;
|
|
}
|
|
|
|
var energyCapacity = powerCores * StationEnergyPerPowerCore;
|
|
var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
|
|
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
|
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
|
{
|
|
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
|
station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
|
|
return;
|
|
}
|
|
|
|
var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds);
|
|
var requiredFuel = generated / StationFuelToEnergyRatio;
|
|
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
|
|
var actualGenerated = consumedFuel * StationFuelToEnergyRatio;
|
|
|
|
RemoveInventory(station.Inventory, "fuel", consumedFuel);
|
|
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
|
|
}
|
|
|
|
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
|
|
var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank");
|
|
if (reactors <= 0 || capacitors <= 0)
|
|
{
|
|
ship.EnergyStored = 0f;
|
|
ship.Inventory.Remove("fuel");
|
|
return;
|
|
}
|
|
|
|
var energyCapacity = capacitors * CapacitorEnergyPerModule;
|
|
var fuelCapacity = reactors * ShipFuelPerReactor;
|
|
var fuelStored = GetInventoryAmount(ship.Inventory, "fuel");
|
|
var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored);
|
|
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
|
{
|
|
ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity);
|
|
ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity);
|
|
return;
|
|
}
|
|
|
|
var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds);
|
|
var requiredFuel = generated / ShipFuelToEnergyRatio;
|
|
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
|
|
var actualGenerated = consumedFuel * ShipFuelToEnergyRatio;
|
|
|
|
RemoveInventory(ship.Inventory, "fuel", consumedFuel);
|
|
ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated);
|
|
}
|
|
|
|
private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount)
|
|
{
|
|
if (ship.EnergyStored + 0.0001f < amount)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount);
|
|
return true;
|
|
}
|
|
|
|
private static bool TryConsumeStationEnergy(StationRuntime station, float amount)
|
|
{
|
|
if (station.EnergyStored + 0.0001f < amount)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount);
|
|
return true;
|
|
}
|
|
|
|
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
|
|
modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal));
|
|
|
|
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
|
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
|
|
|
private static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
|
{
|
|
if (amount <= 0f)
|
|
{
|
|
return;
|
|
}
|
|
|
|
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
|
|
private static bool HasStationModules(StationRuntime station, params string[] modules) =>
|
|
modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
|
|
|
|
private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) =>
|
|
node.ItemId switch
|
|
{
|
|
"ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"),
|
|
"gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"),
|
|
_ => false,
|
|
};
|
|
|
|
private static float GetShipFuelCapacity(ShipRuntime ship) =>
|
|
CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor;
|
|
|
|
private static bool NeedsRefuel(ShipRuntime ship) =>
|
|
GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f);
|
|
|
|
private 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);
|
|
}
|
|
|
|
private static string? GetStorageRequirement(string storageClass) =>
|
|
storageClass switch
|
|
{
|
|
"bulk-solid" => "bulk-bay",
|
|
"bulk-liquid" => "liquid-tank",
|
|
"bulk-gas" => "gas-tank",
|
|
_ => null,
|
|
};
|
|
|
|
private 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.Storage;
|
|
var requiredModule = GetStorageRequirement(storageClass);
|
|
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity))
|
|
{
|
|
return 0f;
|
|
}
|
|
|
|
var used = station.Inventory
|
|
.Where((entry) => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == 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;
|
|
}
|
|
|
|
private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
|
|
recipe.Inputs.All((input) => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
|
|
|
|
private 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);
|
|
|
|
private static bool IsConstructionSiteReady(ConstructionSiteRuntime site) =>
|
|
site.RequiredItems.All((entry) => GetInventoryAmount(site.DeliveredItems, entry.Key) + 0.001f >= entry.Value);
|
|
|
|
private static 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));
|
|
}
|
|
}
|
|
}
|
|
|
|
private static 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 - GetInventoryAmount(site.DeliveredItems, order.ItemId));
|
|
order.RemainingAmount = remaining;
|
|
order.State = remaining <= 0.01f
|
|
? MarketOrderStateKinds.Filled
|
|
: remaining < order.Amount
|
|
? MarketOrderStateKinds.PartiallyFilled
|
|
: MarketOrderStateKinds.Open;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
|
|
{
|
|
if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
|
|
{
|
|
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
|
|
{
|
|
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
|
|
&& world.ModuleRecipes.ContainsKey(moduleId))
|
|
{
|
|
return moduleId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private 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;
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
private static int GetDockingPadCount(StationRuntime station) =>
|
|
CountModules(station.InstalledModules, "dock-bay-small") * 2;
|
|
|
|
private 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;
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
}
|
|
|
|
private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex)
|
|
{
|
|
var padCount = Math.Max(1, GetDockingPadCount(station));
|
|
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
|
|
var radius = station.Definition.Radius + 14f;
|
|
return new Vector3(
|
|
station.Position.X + (MathF.Cos(angle) * radius),
|
|
station.Position.Y,
|
|
station.Position.Z + (MathF.Sin(angle) * radius));
|
|
}
|
|
|
|
private 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.Definition.Radius + 34f;
|
|
return new Vector3(
|
|
station.Position.X + (MathF.Cos(angle) * radius),
|
|
station.Position.Y,
|
|
station.Position.Z + (MathF.Sin(angle) * radius));
|
|
}
|
|
|
|
private 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));
|
|
}
|
|
|
|
private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) =>
|
|
ship.AssignedDockingPadIndex is int padIndex
|
|
? GetDockingPadPosition(station, padIndex)
|
|
: station.Position;
|
|
|
|
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 float GetShipCargoAmount(ShipRuntime ship)
|
|
{
|
|
var cargoItemId = ship.Definition.CargoItemId;
|
|
return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId);
|
|
}
|
|
|
|
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 = 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 };
|
|
commander.ActiveTask.Kind = ship.ControllerTask.Kind;
|
|
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;
|
|
}
|
|
|
|
private 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 == "queued")
|
|
{
|
|
ship.Order.Status = "accepted";
|
|
if (commander?.ActiveOrder is not null)
|
|
{
|
|
commander.ActiveOrder.Status = ship.Order.Status;
|
|
}
|
|
}
|
|
|
|
if (commander is not null)
|
|
{
|
|
SyncShipToCommander(ship, commander);
|
|
}
|
|
}
|
|
|
|
private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
|
|
{
|
|
var commander = GetShipCommander(world, ship);
|
|
if (ship.Order is not null)
|
|
{
|
|
var plannedTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "travel",
|
|
Status = "active",
|
|
CommanderId = commander?.Id,
|
|
TargetEntityId = null,
|
|
TargetSystemId = ship.Order.DestinationSystemId,
|
|
TargetNodeId = ship.SpatialState.DestinationNodeId,
|
|
TargetPosition = ship.Order.DestinationPosition,
|
|
Threshold = world.Balance.ArrivalThreshold,
|
|
};
|
|
ship.ControllerTask = plannedTask;
|
|
if (commander is not null)
|
|
{
|
|
commander.ActiveTask = new CommanderTaskRuntime
|
|
{
|
|
Kind = plannedTask.Kind,
|
|
Status = plannedTask.Status,
|
|
TargetEntityId = plannedTask.TargetEntityId,
|
|
TargetNodeId = plannedTask.TargetNodeId,
|
|
TargetPosition = plannedTask.TargetPosition,
|
|
TargetSystemId = plannedTask.TargetSystemId,
|
|
Threshold = plannedTask.Threshold,
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "auto-mine")
|
|
{
|
|
PlanResourceHarvest(ship, world, "ore", "mining-turret");
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "auto-harvest-gas")
|
|
{
|
|
PlanResourceHarvest(ship, world, "gas", "gas-extractor");
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "construct-station")
|
|
{
|
|
PlanStationConstruction(ship, world);
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0)
|
|
{
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "travel",
|
|
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
|
|
TargetSystemId = ship.SystemId,
|
|
Threshold = 18f,
|
|
};
|
|
return;
|
|
}
|
|
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "idle",
|
|
Threshold = world.Balance.ArrivalThreshold,
|
|
};
|
|
}
|
|
|
|
private 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)
|
|
.OrderByDescending((candidate) => candidate.OreRemaining)
|
|
.FirstOrDefault()
|
|
: world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId);
|
|
|
|
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
|
|
{
|
|
behavior.Kind = "idle";
|
|
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
|
return;
|
|
}
|
|
|
|
behavior.NodeId ??= node.Id;
|
|
if (ship.DockedStationId == refinery.Id)
|
|
{
|
|
if (GetShipCargoAmount(ship) > 0.01f)
|
|
{
|
|
behavior.Phase = "unload";
|
|
}
|
|
else if (NeedsRefuel(ship))
|
|
{
|
|
behavior.Phase = "refuel";
|
|
}
|
|
else if (behavior.Phase is "dock" or "unload" or "refuel")
|
|
{
|
|
behavior.Phase = "undock";
|
|
}
|
|
}
|
|
else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock")
|
|
{
|
|
behavior.Phase = "travel-to-station";
|
|
}
|
|
|
|
switch (behavior.Phase)
|
|
{
|
|
case "extract":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "extract",
|
|
TargetEntityId = node.Id,
|
|
TargetSystemId = node.SystemId,
|
|
TargetPosition = node.Position,
|
|
Threshold = 14f,
|
|
};
|
|
break;
|
|
case "travel-to-station":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "travel",
|
|
TargetEntityId = refinery.Id,
|
|
TargetSystemId = refinery.SystemId,
|
|
TargetPosition = refinery.Position,
|
|
Threshold = refinery.Definition.Radius + 8f,
|
|
};
|
|
break;
|
|
case "dock":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "dock",
|
|
TargetEntityId = refinery.Id,
|
|
TargetSystemId = refinery.SystemId,
|
|
TargetPosition = refinery.Position,
|
|
Threshold = refinery.Definition.Radius + 4f,
|
|
};
|
|
break;
|
|
case "unload":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "unload",
|
|
TargetEntityId = refinery.Id,
|
|
TargetSystemId = refinery.SystemId,
|
|
TargetPosition = refinery.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "refuel":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "refuel",
|
|
TargetEntityId = refinery.Id,
|
|
TargetSystemId = refinery.SystemId,
|
|
TargetPosition = refinery.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "undock":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "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 = "travel",
|
|
TargetEntityId = node.Id,
|
|
TargetSystemId = node.SystemId,
|
|
TargetPosition = node.Position,
|
|
Threshold = 18f,
|
|
};
|
|
behavior.Phase = "travel-to-node";
|
|
break;
|
|
}
|
|
}
|
|
|
|
private 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 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 = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
|
return;
|
|
}
|
|
|
|
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
|
|
behavior.ModuleId = moduleId;
|
|
if (moduleId is null)
|
|
{
|
|
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
|
return;
|
|
}
|
|
|
|
if (ship.DockedStationId == station.Id)
|
|
{
|
|
if (NeedsRefuel(ship))
|
|
{
|
|
behavior.Phase = "refuel";
|
|
}
|
|
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site))
|
|
{
|
|
behavior.Phase = "deliver-to-site";
|
|
}
|
|
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(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 is not "travel-to-station" and not "dock")
|
|
{
|
|
behavior.Phase = "travel-to-station";
|
|
}
|
|
|
|
switch (behavior.Phase)
|
|
{
|
|
case "dock":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "dock",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = station.Definition.Radius + 4f,
|
|
};
|
|
break;
|
|
case "refuel":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "refuel",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "construct-module":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "construct-module",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "deliver-to-site":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "deliver-construction",
|
|
TargetEntityId = site?.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "build-site":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "build-construction-site",
|
|
TargetEntityId = site?.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
case "wait-for-materials":
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "idle",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = 0f,
|
|
};
|
|
break;
|
|
default:
|
|
ship.ControllerTask = new ControllerTaskRuntime
|
|
{
|
|
Kind = "travel",
|
|
TargetEntityId = station.Id,
|
|
TargetSystemId = station.SystemId,
|
|
TargetPosition = station.Position,
|
|
Threshold = station.Definition.Radius + 8f,
|
|
};
|
|
behavior.Phase = "travel-to-station";
|
|
break;
|
|
}
|
|
}
|
|
|
|
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
var task = ship.ControllerTask;
|
|
switch (task.Kind)
|
|
{
|
|
case "idle":
|
|
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
case "travel":
|
|
return UpdateTravel(ship, world, deltaSeconds);
|
|
case "extract":
|
|
return UpdateExtract(ship, world, deltaSeconds);
|
|
case "dock":
|
|
return UpdateDock(ship, world, deltaSeconds);
|
|
case "unload":
|
|
return UpdateUnload(ship, world, deltaSeconds);
|
|
case "refuel":
|
|
return UpdateRefuel(ship, world, deltaSeconds);
|
|
case "deliver-construction":
|
|
return UpdateDeliverConstruction(ship, world, deltaSeconds);
|
|
case "build-construction-site":
|
|
return UpdateBuildConstructionSite(ship, world, deltaSeconds);
|
|
case "load-workers":
|
|
return UpdateLoadWorkers(ship, world, deltaSeconds);
|
|
case "unload-workers":
|
|
return UpdateUnloadWorkers(ship, world, deltaSeconds);
|
|
case "construct-module":
|
|
return UpdateConstructModule(ship, world, deltaSeconds);
|
|
case "undock":
|
|
return UpdateUndock(ship, world, deltaSeconds);
|
|
default:
|
|
ship.State = "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 = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var targetPosition = task.TargetPosition.Value;
|
|
var targetNode = ResolveTravelTargetNode(world, task, targetPosition);
|
|
ship.TargetPosition = targetPosition;
|
|
|
|
if (ship.SystemId != task.TargetSystemId)
|
|
{
|
|
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode);
|
|
}
|
|
|
|
var currentNode = ResolveCurrentNode(world, ship);
|
|
if (targetNode is not null && currentNode is not null && !string.Equals(currentNode.Id, targetNode.Id, StringComparison.Ordinal))
|
|
{
|
|
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetNode);
|
|
}
|
|
|
|
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode, task.Threshold);
|
|
}
|
|
|
|
private static NodeRuntime? ResolveTravelTargetNode(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
|
|
{
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
|
|
if (station?.NodeId is not null)
|
|
{
|
|
return world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == station.NodeId);
|
|
}
|
|
|
|
var node = world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
|
|
if (node is not null)
|
|
{
|
|
return node;
|
|
}
|
|
}
|
|
|
|
return world.SpatialNodes
|
|
.Where((candidate) => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId)
|
|
.OrderBy((candidate) => candidate.Position.DistanceTo(targetPosition))
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private static NodeRuntime? ResolveCurrentNode(SimulationWorld world, ShipRuntime ship)
|
|
{
|
|
if (ship.SpatialState.CurrentNodeId is not null)
|
|
{
|
|
return world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == ship.SpatialState.CurrentNodeId);
|
|
}
|
|
|
|
return world.SpatialNodes
|
|
.Where((candidate) => candidate.SystemId == ship.SystemId)
|
|
.OrderBy((candidate) => candidate.Position.DistanceTo(ship.Position))
|
|
.FirstOrDefault();
|
|
}
|
|
|
|
private string UpdateLocalTravel(
|
|
ShipRuntime ship,
|
|
SimulationWorld world,
|
|
float deltaSeconds,
|
|
string targetSystemId,
|
|
Vector3 targetPosition,
|
|
NodeRuntime? targetNode,
|
|
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 = targetNode?.Id;
|
|
|
|
if (distance <= threshold)
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
|
|
ship.Position = targetPosition;
|
|
ship.TargetPosition = ship.Position;
|
|
ship.SystemId = targetSystemId;
|
|
ship.SpatialState.CurrentNodeId = targetNode?.Id;
|
|
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
|
ship.State = "arriving";
|
|
return "arrived";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "local-flight";
|
|
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
|
|
return "none";
|
|
}
|
|
|
|
private string UpdateWarpTransit(
|
|
ShipRuntime ship,
|
|
SimulationWorld world,
|
|
float deltaSeconds,
|
|
Vector3 targetPosition,
|
|
NodeRuntime targetNode)
|
|
{
|
|
var transit = ship.SpatialState.Transit;
|
|
if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetNode.Id)
|
|
{
|
|
transit = new ShipTransitRuntime
|
|
{
|
|
Regime = MovementRegimeKinds.Warp,
|
|
OriginNodeId = ship.SpatialState.CurrentNodeId,
|
|
DestinationNodeId = targetNode.Id,
|
|
StartedAtUtc = world.GeneratedAtUtc,
|
|
};
|
|
ship.SpatialState.Transit = transit;
|
|
}
|
|
|
|
ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace;
|
|
ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp;
|
|
ship.SpatialState.CurrentNodeId = null;
|
|
ship.SpatialState.CurrentBubbleId = null;
|
|
ship.SpatialState.DestinationNodeId = targetNode.Id;
|
|
|
|
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
|
if (ship.State != "warping")
|
|
{
|
|
if (ship.State != "spooling-warp")
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "spooling-warp";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "warping";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
|
|
? ship.Position.DistanceTo(targetPosition)
|
|
: (world.SpatialNodes.FirstOrDefault((candidate) => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
|
|
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
|
|
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
|
|
return ship.Position.DistanceTo(targetPosition) <= 18f
|
|
? CompleteTransitArrival(ship, targetNode.SystemId, targetPosition, targetNode)
|
|
: "none";
|
|
}
|
|
|
|
private string UpdateFtlTransit(
|
|
ShipRuntime ship,
|
|
SimulationWorld world,
|
|
float deltaSeconds,
|
|
string targetSystemId,
|
|
Vector3 targetPosition,
|
|
NodeRuntime? targetNode)
|
|
{
|
|
var destinationNodeId = targetNode?.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.CurrentNodeId,
|
|
DestinationNodeId = destinationNodeId,
|
|
StartedAtUtc = world.GeneratedAtUtc,
|
|
};
|
|
ship.SpatialState.Transit = transit;
|
|
}
|
|
|
|
ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace;
|
|
ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit;
|
|
ship.SpatialState.CurrentNodeId = null;
|
|
ship.SpatialState.CurrentBubbleId = null;
|
|
ship.SpatialState.DestinationNodeId = destinationNodeId;
|
|
|
|
if (ship.State != "ftl")
|
|
{
|
|
if (ship.State != "spooling-ftl")
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "spooling-ftl";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "ftl";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var totalDistance = MathF.Max(0.001f, ship.Position.DistanceTo(targetPosition));
|
|
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds);
|
|
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
|
|
return ship.Position.DistanceTo(targetPosition) <= 24f
|
|
? CompleteTransitArrival(ship, targetSystemId, targetPosition, targetNode)
|
|
: "none";
|
|
}
|
|
|
|
private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, NodeRuntime? targetNode)
|
|
{
|
|
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.CurrentNodeId = targetNode?.Id;
|
|
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
|
ship.SpatialState.DestinationNodeId = targetNode?.Id;
|
|
ship.State = "arriving";
|
|
return "arrived";
|
|
}
|
|
|
|
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))
|
|
{
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = task.TargetPosition.Value;
|
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
|
if (distance > task.Threshold)
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "mining-approach";
|
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "mining";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
var cargoAmount = GetShipCargoAmount(ship);
|
|
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount);
|
|
mined = MathF.Min(mined, node.OreRemaining);
|
|
if (ship.Definition.CargoItemId is not null)
|
|
{
|
|
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
|
|
}
|
|
node.OreRemaining -= mined;
|
|
if (node.OreRemaining <= 0f)
|
|
{
|
|
node.OreRemaining = node.MaxOre;
|
|
}
|
|
|
|
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 = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
|
|
if (padIndex is null)
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "awaiting-dock";
|
|
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
|
|
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
|
|
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * 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;
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "docking-approach";
|
|
ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds);
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "docking";
|
|
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "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 = "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 = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "transferring";
|
|
var cargoItemId = ship.Definition.CargoItemId;
|
|
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
|
|
if (cargoItemId is not null)
|
|
{
|
|
var accepted = TryAddStationInventory(world, station, cargoItemId, moved);
|
|
RemoveInventory(ship.Inventory, cargoItemId, accepted);
|
|
moved = accepted;
|
|
}
|
|
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
|
|
if (faction is not null && cargoItemId == "ore")
|
|
{
|
|
faction.OreMined += moved;
|
|
faction.Credits += moved * 0.4f;
|
|
}
|
|
|
|
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none";
|
|
}
|
|
|
|
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
ship.State = "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 = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "refueling";
|
|
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel"));
|
|
var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel"));
|
|
if (moved > 0.01f)
|
|
{
|
|
RemoveInventory(station.Inventory, "fuel", moved);
|
|
AddInventory(ship.Inventory, "fuel", moved);
|
|
}
|
|
|
|
return !NeedsRefuel(ship) ? "refueled" : "none";
|
|
}
|
|
|
|
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null)
|
|
{
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
|
|
{
|
|
ship.AssignedDockingPadIndex = null;
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
|
|
{
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "waiting-materials";
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
return "none";
|
|
}
|
|
|
|
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
|
|
{
|
|
ship.State = "construction-blocked";
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "constructing";
|
|
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
|
|
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
station.InstalledModules.Add(station.ActiveConstruction.ModuleId);
|
|
station.ActiveConstruction = null;
|
|
return "module-constructed";
|
|
}
|
|
|
|
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
var site = world.ConstructionSites.FirstOrDefault((candidate) => candidate.Id == ship.ControllerTask.TargetEntityId);
|
|
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
|
|
{
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "delivering-construction";
|
|
|
|
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(site) ? "construction-delivered" : "none";
|
|
}
|
|
|
|
return IsConstructionSiteReady(site) ? "construction-delivered" : "none";
|
|
}
|
|
|
|
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null)
|
|
{
|
|
ship.State = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
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 = "idle";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
|
{
|
|
ship.State = "waiting-materials";
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
return "none";
|
|
}
|
|
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
|
ship.Position = ship.TargetPosition;
|
|
ship.ActionTimer = 0f;
|
|
ship.State = "constructing";
|
|
site.AssignedConstructorShipIds.Add(ship.Id);
|
|
site.Progress += deltaSeconds;
|
|
if (site.Progress < recipe.Duration)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
station.InstalledModules.Add(site.BlueprintId);
|
|
PrepareNextConstructionSiteStep(world, station, site);
|
|
return "site-constructed";
|
|
}
|
|
|
|
private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
|
|
{
|
|
ship.State = "blocked";
|
|
return "failed";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
if (station is null || station.Population <= 0.01f)
|
|
{
|
|
ship.State = "idle";
|
|
return "none";
|
|
}
|
|
|
|
var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
|
|
transfer = MathF.Min(transfer, 4f * deltaSeconds);
|
|
if (transfer <= 0.01f)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
station.Population = MathF.Max(0f, station.Population - transfer);
|
|
ship.WorkerPopulation += transfer;
|
|
ship.State = "loading";
|
|
return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none";
|
|
}
|
|
|
|
private string UpdateUnloadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
|
|
{
|
|
ship.State = "blocked";
|
|
return "failed";
|
|
}
|
|
|
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
|
if (station is null || ship.WorkerPopulation <= 0.01f)
|
|
{
|
|
ship.State = "idle";
|
|
return "none";
|
|
}
|
|
|
|
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
|
|
transfer = MathF.Min(transfer, 4f * deltaSeconds);
|
|
if (transfer <= 0.01f)
|
|
{
|
|
return "none";
|
|
}
|
|
|
|
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer);
|
|
station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer);
|
|
ship.State = "unloading";
|
|
return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none";
|
|
}
|
|
|
|
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
|
{
|
|
var task = ship.ControllerTask;
|
|
if (ship.DockedStationId is null || task.TargetPosition is null)
|
|
{
|
|
ship.State = "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;
|
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
|
{
|
|
ship.State = "power-starved";
|
|
ship.TargetPosition = ship.Position;
|
|
return "none";
|
|
}
|
|
|
|
ship.State = "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";
|
|
}
|
|
|
|
private void AdvanceControlState(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 = "idle";
|
|
if (commander is not null)
|
|
{
|
|
commander.ActiveOrder = null;
|
|
commander.ActiveTask = new CommanderTaskRuntime
|
|
{
|
|
Kind = ShipTaskKinds.Idle,
|
|
Status = "completed",
|
|
TargetSystemId = ship.SystemId,
|
|
Threshold = 0f,
|
|
};
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "auto-mine")
|
|
{
|
|
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
|
{
|
|
case ("travel-to-node", "arrived"):
|
|
ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
|
|
break;
|
|
case ("extract", "cargo-full"):
|
|
ship.DefaultBehavior.Phase = "travel-to-station";
|
|
break;
|
|
case ("travel-to-station", "arrived"):
|
|
ship.DefaultBehavior.Phase = "dock";
|
|
break;
|
|
case ("dock", "docked"):
|
|
ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel";
|
|
break;
|
|
case ("unload", "unloaded"):
|
|
ship.DefaultBehavior.Phase = "refuel";
|
|
break;
|
|
case ("refuel", "refueled"):
|
|
ship.DefaultBehavior.Phase = "undock";
|
|
break;
|
|
case ("undock", "undocked"):
|
|
ship.DefaultBehavior.Phase = "travel-to-node";
|
|
ship.DefaultBehavior.NodeId = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "auto-harvest-gas")
|
|
{
|
|
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
|
{
|
|
case ("travel-to-node", "arrived"):
|
|
ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
|
|
break;
|
|
case ("extract", "cargo-full"):
|
|
ship.DefaultBehavior.Phase = "travel-to-station";
|
|
break;
|
|
case ("travel-to-station", "arrived"):
|
|
ship.DefaultBehavior.Phase = "dock";
|
|
break;
|
|
case ("dock", "docked"):
|
|
ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel";
|
|
break;
|
|
case ("unload", "unloaded"):
|
|
ship.DefaultBehavior.Phase = "refuel";
|
|
break;
|
|
case ("refuel", "refueled"):
|
|
ship.DefaultBehavior.Phase = "undock";
|
|
break;
|
|
case ("undock", "undocked"):
|
|
ship.DefaultBehavior.Phase = "travel-to-node";
|
|
ship.DefaultBehavior.NodeId = null;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "construct-station")
|
|
{
|
|
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
|
{
|
|
case ("travel-to-station", "arrived"):
|
|
ship.DefaultBehavior.Phase = "dock";
|
|
break;
|
|
case ("dock", "docked"):
|
|
ship.DefaultBehavior.Phase = NeedsRefuel(ship) ? "refuel" : "deliver-to-site";
|
|
break;
|
|
case ("refuel", "refueled"):
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
|
|
{
|
|
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
|
|
}
|
|
|
|
if (commander is not null)
|
|
{
|
|
SyncShipToCommander(ship, commander);
|
|
if (commander.ActiveTask is not null)
|
|
{
|
|
commander.ActiveTask.Status = controllerEvent == "none" ? "active" : "completed";
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void TrackHistory(ShipRuntime ship)
|
|
{
|
|
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}";
|
|
if (signature == ship.LastSignature)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ship.LastSignature = signature;
|
|
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}");
|
|
if (ship.History.Count > 18)
|
|
{
|
|
ship.History.RemoveAt(0);
|
|
}
|
|
}
|
|
|
|
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
|
|
|
|
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
|
|
private readonly record struct LagrangePointPlacement(string Designation, Vector3 Position);
|
|
}
|