using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; public sealed class SimulationEngine { public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) { var events = new List(); 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; RefreshControlLayers(ship); PlanControllerTask(ship, world); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); AdvanceControlState(ship, controllerEvent); ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); TrackHistory(ship); EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); } world.GeneratedAtUtc = DateTimeOffset.UtcNow; return new WorldDelta( sequence, world.TickIntervalMs, world.GeneratedAtUtc, false, events, BuildNodeDeltas(world), BuildStationDeltas(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.StarColor, system.Definition.StarSize, system.Definition.Planets.Select((planet) => new PlanetSnapshot( planet.Label, planet.OrbitRadius, planet.Size, planet.Color, planet.HasRing)).ToList())).ToList(), world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot( node.Id, node.SystemId, node.Position, node.OreRemaining, node.MaxOre, node.ItemId)).ToList(), world.Stations.Select(ToStationDelta).Select((station) => new StationSnapshot( station.Id, station.Label, station.Category, station.SystemId, station.Position, station.Color, station.DockedShips, station.OreStored, station.RefinedStock, station.FactionId)).ToList(), world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot( ship.Id, ship.Label, ship.Role, ship.ShipClass, ship.SystemId, ship.Position, ship.Velocity, ship.TargetPosition, ship.State, ship.OrderKind, ship.DefaultBehaviorKind, ship.ControllerTaskKind, ship.Cargo, ship.CargoCapacity, ship.CargoItemId, ship.FactionId, ship.Health, ship.History)).ToList(), world.Factions.Select(ToFactionDelta).Select((faction) => new FactionSnapshot( faction.Id, faction.Label, faction.Color, faction.Credits, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, faction.ShipsLost)).ToList()); } public void PrimeDeltaBaseline(SimulationWorld world) { foreach (var node in world.Nodes) { node.LastDeltaSignature = BuildNodeSignature(node); } foreach (var station in world.Stations) { station.LastDeltaSignature = BuildStationSignature(station); } 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 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 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 BuildStationSignature(StationRuntime station) => $"{station.SystemId}|{station.OreStored:0.###}|{station.RefinedStock:0.###}|{station.DockedShipIds.Count}"; 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.Cargo.ToString("0.###"), ship.Health.ToString("0.###")); private static string BuildFactionSignature(FactionRuntime faction) => $"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}"; private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( node.Id, node.SystemId, ToDto(node.Position), node.OreRemaining, node.MaxOre, node.ItemId); private static StationDelta ToStationDelta(StationRuntime station) => new( station.Id, station.Definition.Label, station.Definition.Category, station.SystemId, ToDto(station.Position), station.Definition.Color, station.DockedShipIds.Count, station.OreStored, station.RefinedStock, station.FactionId); 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.Cargo, ship.Definition.CargoCapacity, ship.Definition.CargoItemId, ship.FactionId, ship.Health, ship.History.ToList()); private static FactionDelta ToFactionDelta(FactionRuntime faction) => new( faction.Id, faction.Label, faction.Color, faction.Credits, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, faction.ShipsLost); 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) { foreach (var station in world.Stations) { if (station.Definition.Category != "refining" || station.OreStored < 60f) { continue; } station.ProcessTimer += deltaSeconds; if (station.ProcessTimer < 8f) { continue; } station.ProcessTimer = 0f; station.OreStored -= 60f; station.RefinedStock += 60f; events.Add(new SimulationEventRecord("station", station.Id, "refined", $"{station.Definition.Label} refined 60 ore", DateTimeOffset.UtcNow)); var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId); if (faction is not null) { faction.GoodsProduced += 60f; faction.Credits += 18f; } } } private void RefreshControlLayers(ShipRuntime ship) { if (ship.Order is not null && ship.Order.Status == "queued") { ship.Order.Status = "accepted"; } } private void PlanControllerTask(ShipRuntime ship, SimulationWorld world) { if (ship.Order is not null) { ship.ControllerTask = new ControllerTaskRuntime { Kind = "travel", TargetEntityId = null, TargetSystemId = ship.Order.DestinationSystemId, TargetPosition = ship.Order.DestinationPosition, Threshold = world.Balance.ArrivalThreshold, }; return; } if (ship.DefaultBehavior.Kind == "auto-mine") { PlanAutoMine(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 PlanAutoMine(ShipRuntime ship, SimulationWorld world) { var behavior = ship.DefaultBehavior; var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId); var node = behavior.NodeId is null ? world.Nodes .Where((candidate) => candidate.SystemId == behavior.AreaSystemId) .OrderByDescending((candidate) => candidate.OreRemaining) .FirstOrDefault() : world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId); if (refinery is null || node is null) { behavior.Kind = "idle"; ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold }; return; } behavior.NodeId ??= node.Id; 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 "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 string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { var task = ship.ControllerTask; switch (task.Kind) { case "idle": 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 "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"; } ship.TargetPosition = task.TargetPosition.Value; var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance <= task.Threshold) { ship.Position = task.TargetPosition.Value; ship.TargetPosition = ship.Position; ship.SystemId = task.TargetSystemId; ship.State = "arriving"; return "arrived"; } var speed = ship.Definition.Speed; if (ship.SystemId != task.TargetSystemId) { ship.State = distance > 800f ? "ftl" : "spooling-ftl"; speed = ship.Definition.FtlSpeed; } else if (distance > 200f) { ship.State = distance > 500f ? "warping" : "spooling-warp"; speed = ship.Definition.Speed * 4.5f; } else { ship.State = "approaching"; speed = ship.Definition.Speed; } ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds); return "none"; } 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) { 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.State = "mining-approach"; ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); return "none"; } ship.State = "mining"; ship.ActionTimer += deltaSeconds; if (ship.ActionTimer < 1f) { return "none"; } ship.ActionTimer = 0f; var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - ship.Cargo); mined = MathF.Min(mined, node.OreRemaining); ship.Cargo += mined; node.OreRemaining -= mined; if (node.OreRemaining <= 0f) { node.OreRemaining = node.MaxOre; } return ship.Cargo >= 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"; } ship.TargetPosition = task.TargetPosition.Value; var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance > task.Threshold) { ship.State = "docking-approach"; ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); return "none"; } ship.State = "docking"; ship.ActionTimer += deltaSeconds; if (ship.ActionTimer < world.Balance.DockingDuration) { return "none"; } ship.ActionTimer = 0f; ship.State = "docked"; ship.DockedStationId = station.Id; station.DockedShipIds.Add(ship.Id); ship.Position = station.Position; ship.TargetPosition = station.Position; 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.State = "idle"; ship.TargetPosition = ship.Position; return "none"; } ship.TargetPosition = station.Position; ship.State = "transferring"; var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds); ship.Cargo -= moved; station.OreStored += moved; var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId); if (faction is not null) { faction.OreMined += moved; faction.Credits += moved * 0.4f; } return ship.Cargo <= 0.01f ? "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"; } ship.TargetPosition = task.TargetPosition.Value; var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); station?.DockedShipIds.Remove(ship.Id); ship.DockedStationId = null; ship.State = "undocking"; ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); return ship.Position.DistanceTo(task.TargetPosition.Value) <= task.Threshold ? "undocked" : "none"; } private void AdvanceControlState(ShipRuntime ship, string controllerEvent) { if (ship.Order is not null && controllerEvent == "arrived") { ship.Order = null; ship.ControllerTask.Kind = "idle"; return; } if (ship.DefaultBehavior.Kind == "auto-mine") { switch (ship.DefaultBehavior.Phase, controllerEvent) { case ("travel-to-node", "arrived"): ship.DefaultBehavior.Phase = ship.Cargo >= 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 = "unload"; break; case ("unload", "unloaded"): ship.DefaultBehavior.Phase = "undock"; break; case ("undock", "undocked"): ship.DefaultBehavior.Phase = "travel-to-node"; ship.DefaultBehavior.NodeId = 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; } } private static void TrackHistory(ShipRuntime ship) { var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{ship.Cargo: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={ship.Cargo:0.#}"); if (ship.History.Count > 18) { ship.History.RemoveAt(0); } } private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z); }