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(); 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 BuildNodeDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildSpatialNodeDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildLocalBubbleDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildStationDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildClaimDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildConstructionSiteDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildMarketOrderDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildPolicyDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildShipDeltas(SimulationWorld world) { var deltas = new List(); 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 BuildFactionDeltas(SimulationWorld world) { var deltas = new List(); 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 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 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 ToInventoryEntries(IReadOnlyDictionary 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 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 events) { var factionPopulation = new Dictionary(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 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(); 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 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 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 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 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 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 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 modules, string moduleId) => modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal)); private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => inventory.TryGetValue(itemId, out var amount) ? amount : 0f; private static void AddInventory(IDictionary inventory, string itemId, float amount) { if (amount <= 0f) { return; } inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; } private static float RemoveInventory(IDictionary inventory, string itemId, float amount) { var current = GetInventoryAmount((IReadOnlyDictionary)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 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 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); }