using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; public sealed class SimulationEngine { public void Tick(SimulationWorld world, float deltaSeconds) { UpdateStations(world, deltaSeconds); foreach (var ship in world.Ships) { RefreshControlLayers(ship); PlanControllerTask(ship, world); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); AdvanceControlState(ship, controllerEvent); TrackHistory(ship); } world.GeneratedAtUtc = DateTimeOffset.UtcNow; } public WorldSnapshot BuildSnapshot(SimulationWorld world) { return new WorldSnapshot( world.Label, world.Seed, 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((node) => new ResourceNodeSnapshot( node.Id, node.SystemId, ToDto(node.Position), node.OreRemaining, node.MaxOre, node.ItemId)).ToList(), world.Stations.Select((station) => new StationSnapshot( 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)).ToList(), world.Ships.Select((ship) => new ShipSnapshot( ship.Id, ship.Definition.Label, ship.Definition.Role, ship.Definition.ShipClass, ship.SystemId, ToDto(ship.Position), 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())).ToList(), world.Factions.Select((faction) => new FactionSnapshot( faction.Id, faction.Label, faction.Color, faction.Credits, faction.OreMined, faction.GoodsProduced, faction.ShipsBuilt, faction.ShipsLost)).ToList()); } private void UpdateStations(SimulationWorld world, float deltaSeconds) { 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; 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"; 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"; 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"; return "none"; } 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); ship.TargetPosition = task.TargetPosition.Value; 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"; return "none"; } 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"; return "none"; } 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; return "docked"; } private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds) { if (ship.DockedStationId is null) { ship.State = "idle"; return "none"; } var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId); if (station is null) { ship.DockedStationId = null; ship.State = "idle"; return "none"; } 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"; return "none"; } 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); }