using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; public sealed class SpatialBuilder { internal static bool IsConstructibleAnchorKind(SpatialNodeKind kind) => kind is SpatialNodeKind.Planet or SpatialNodeKind.Moon or SpatialNodeKind.LagrangePoint; internal static string? ResolveCompatibleCelestialId(AnchorRuntime? anchor) => anchor is not null && string.Equals(anchor.SourceEntityKind, "celestial", StringComparison.Ordinal) ? anchor.SourceEntityId : null; internal ScenarioSpatialLayout BuildLayout(IReadOnlyList systems) { var systemGraphs = systems.ToDictionary( system => system.Definition.Id, BuildSystemSpatialGraph, StringComparer.Ordinal); var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList(); var anchors = celestials.Select(celestial => new AnchorRuntime { Id = celestial.Id, SystemId = celestial.SystemId, Kind = celestial.Kind, Position = celestial.Position, LocalSpaceRadius = celestial.LocalSpaceRadius, ParentAnchorId = celestial.ParentAnchorId, OrbitReferenceId = celestial.OrbitReferenceId, OccupyingStructureId = celestial.OccupyingStructureId, SourceEntityKind = "celestial", SourceEntityId = celestial.Id, }).ToList(); var nodes = new List(); var nodeIdCounter = 0; foreach (var system in systems) { var systemGraph = systemGraphs[system.Definition.Id]; foreach (var node in system.Definition.ResourceNodes) { var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node); var nodeId = $"node-{++nodeIdCounter}"; var localPosition = ComputeResourceNodeLocalPosition(node); var anchorPosition = anchorCelestial is null ? localPosition : Add(anchorCelestial.Position, localPosition); nodes.Add(new ResourceNodeRuntime { Id = nodeId, AnchorId = nodeId, SystemId = system.Definition.Id, Position = localPosition, SourceKind = node.SourceKind, ItemId = node.ItemId, LocalSpaceRadius = LocalSpaceRadius, OrbitRadius = node.RadiusOffset, OrbitPhase = node.Angle, OrbitInclination = DegreesToRadians(node.InclinationDegrees), OreRemaining = node.OreAmount, MaxOre = node.OreAmount, }); nodes[^1].Deposits.AddRange(BuildResourceDeposits(system.Definition.Id, nodeId, node, node.OreAmount)); anchors.Add(new AnchorRuntime { Id = nodeId, SystemId = system.Definition.Id, Kind = SpatialNodeKind.ResourceNode, Position = anchorPosition, LocalSpaceRadius = LocalSpaceRadius, ParentAnchorId = anchorCelestial?.Id, SourceEntityKind = "resource-node", SourceEntityId = nodeId, }); } } return new ScenarioSpatialLayout(systemGraphs, anchors, celestials, nodes); } private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) { var celestials = new List(); var lagrangeNodesByPlanetIndex = new Dictionary>(); for (var starIndex = 0; starIndex < system.Definition.Stars.Count; starIndex += 1) { AddCelestial( celestials, id: $"node-{system.Definition.Id}-star-{starIndex + 1}", systemId: system.Definition.Id, kind: SpatialNodeKind.Star, position: Vector3.Zero, localSpaceRadius: LocalSpaceRadius); } var primaryStarNodeId = $"node-{system.Definition.Id}-star-1"; for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1) { var planet = system.Definition.Planets[planetIndex]; var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}"; var planetPosition = ComputePlanetPosition(planet); var planetCelestial = AddCelestial( celestials, id: planetNodeId, systemId: system.Definition.Id, kind: SpatialNodeKind.Planet, position: planetPosition, localSpaceRadius: LocalSpaceRadius, parentAnchorId: primaryStarNodeId); var lagrangeNodes = new Dictionary(StringComparer.Ordinal); foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet)) { var lagrangeCelestial = AddCelestial( celestials, id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}", systemId: system.Definition.Id, kind: SpatialNodeKind.LagrangePoint, position: point.Position, localSpaceRadius: LocalSpaceRadius, parentAnchorId: planetCelestial.Id, orbitReferenceId: point.Designation); lagrangeNodes[point.Designation] = lagrangeCelestial; } lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes; for (var moonIndex = 0; moonIndex < planet.Moons.Count; moonIndex += 1) { var moon = planet.Moons[moonIndex]; var moonPosition = ComputeMoonPosition(planetPosition, moon); AddCelestial( celestials, id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}", systemId: system.Definition.Id, kind: SpatialNodeKind.Moon, position: moonPosition, localSpaceRadius: LocalSpaceRadius, parentAnchorId: planetCelestial.Id); } } return new SystemSpatialGraph(system.Definition.Id, celestials, lagrangeNodesByPlanetIndex); } private static CelestialRuntime AddCelestial( ICollection celestials, string id, string systemId, SpatialNodeKind kind, Vector3 position, float localSpaceRadius, string? parentAnchorId = null, string? orbitReferenceId = null) { var celestial = new CelestialRuntime { Id = id, SystemId = systemId, Kind = kind, Position = position, LocalSpaceRadius = localSpaceRadius, ParentAnchorId = parentAnchorId, OrbitReferenceId = orbitReferenceId, }; celestials.Add(celestial); return celestial; } private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet) { var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); var tangential = new Vector3(-radial.Z, 0f, radial.X); var orbitRadiusKm = MathF.Sqrt(planetPosition.X * planetPosition.X + planetPosition.Z * planetPosition.Z); var offset = ComputePlanetLocalLagrangeOffset(orbitRadiusKm, planet); var triangularAngle = MathF.PI / 3f; yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset))); yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset))); yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadiusKm)); yield return new LagrangePointPlacement( "L4", Add( Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadiusKm * MathF.Sin(triangularAngle)))); yield return new LagrangePointPlacement( "L5", Add( Scale(radial, orbitRadiusKm * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadiusKm * MathF.Sin(triangularAngle)))); } private static float ComputePlanetLocalLagrangeOffset(float orbitRadiusKm, PlanetDefinition planet) { var planetMassProxy = EstimatePlanetMassRatio(planet); var hillLikeOffset = orbitRadiusKm * MathF.Cbrt(MathF.Max(planetMassProxy / 3f, 1e-9f)); var minimumOffset = MathF.Max(planet.Size * 4f, 25000f); return MathF.Max(minimumOffset, hillLikeOffset); } private static float EstimatePlanetMassRatio(PlanetDefinition planet) { var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f); var densityFactor = planet.PlanetType switch { "gas-giant" => 0.24f, "ice-giant" => 0.18f, "oceanic" => 0.95f, "ice" => 0.7f, _ => 1f, }; var earthMasses = MathF.Pow(earthRadiusRatio, 3f) * densityFactor; return earthMasses / 332_946f; } internal static StationPlacement ResolveStationPlacement( InitialStationDefinition plan, SystemRuntime system, SystemSpatialGraph graph, IReadOnlyCollection existingAnchors) { if (plan.PlanetIndex is int planetIndex && graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes)) { var designation = ResolveLagrangeDesignation(plan.LagrangeSide); if (lagrangeNodes.TryGetValue(designation, out var lagrangeCelestial)) { var lagrangeAnchor = existingAnchors.First(anchor => string.Equals(anchor.Id, lagrangeCelestial.Id, StringComparison.Ordinal)); return new StationPlacement(lagrangeAnchor, lagrangeCelestial, lagrangeAnchor.Position); } } if (plan.Position is { Length: 3 }) { var targetPosition = NormalizeScenarioPoint(system, plan.Position); var preferredAnchor = existingAnchors .Where(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.LagrangePoint) .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition)) .FirstOrDefault() ?? existingAnchors .Where(anchor => anchor.SystemId == system.Definition.Id && IsConstructibleAnchorKind(anchor.Kind)) .OrderBy(anchor => anchor.Position.DistanceTo(targetPosition)) .First(); var preferredCelestial = graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, ResolveCompatibleCelestialId(preferredAnchor), StringComparison.Ordinal)); return new StationPlacement(preferredAnchor, preferredCelestial, preferredAnchor.Position); } var fallbackAnchor = existingAnchors .Where(anchor => anchor.SystemId == system.Definition.Id) .FirstOrDefault(anchor => anchor.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(anchor.OccupyingStructureId)) ?? existingAnchors.First(anchor => anchor.SystemId == system.Definition.Id && anchor.Kind == SpatialNodeKind.Planet); var fallbackCelestial = graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, ResolveCompatibleCelestialId(fallbackAnchor), StringComparison.Ordinal)); return new StationPlacement(fallbackAnchor, fallbackCelestial, fallbackAnchor.Position); } private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch { < 0 => "L4", > 0 => "L5", _ => "L1", }; private static CelestialRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition) { if (!string.IsNullOrWhiteSpace(definition.AnchorReference)) { var anchorId = definition.AnchorReference.ToLowerInvariant() switch { var reference when reference.StartsWith("star-", StringComparison.Ordinal) => $"node-{graph.SystemId}-{reference}", var reference when reference.StartsWith("planet-", StringComparison.Ordinal) => $"node-{graph.SystemId}-{reference}", _ => null, }; if (anchorId is not null) { return graph.Celestials.FirstOrDefault(c => string.Equals(c.Id, anchorId, StringComparison.Ordinal)); } } if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0) { return null; } if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0) { var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}"; return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId); } var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}"; return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId); } private static Vector3 ComputeResourceNodeLocalPosition(ResourceNodeDefinition definition) { var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.04f, 25000f); return new Vector3( MathF.Cos(definition.Angle) * definition.RadiusOffset, verticalOffset, MathF.Sin(definition.Angle) * definition.RadiusOffset); } private static IReadOnlyList BuildResourceDeposits( string systemId, string nodeId, ResourceNodeDefinition definition, float oreAmount) { var depositCount = Math.Clamp((int)MathF.Round(MathF.Sqrt(MathF.Max(oreAmount, 1f)) / 18f), 4, 12); var deposits = new List(depositCount); var weightTotal = 0f; var weights = new float[depositCount]; for (var index = 0; index < depositCount; index += 1) { var weight = 0.8f + (Hash01(systemId, nodeId, $"weight-{index}") * 1.6f); weights[index] = weight; weightTotal += weight; } var scatterRadius = MathF.Max(140f, LocalSpaceRadius * 0.58f); for (var index = 0; index < depositCount; index += 1) { var angle = Hash01(systemId, nodeId, $"angle-{index}") * MathF.PI * 2f; var radiusFactor = 0.22f + (Hash01(systemId, nodeId, $"radius-{index}") * 0.74f); var radius = scatterRadius * MathF.Sqrt(radiusFactor); var vertical = (Hash01(systemId, nodeId, $"vertical-{index}") - 0.5f) * MathF.Max(60f, scatterRadius * 0.14f); var localPosition = new Vector3( MathF.Cos(angle) * radius, vertical, MathF.Sin(angle) * radius); var maxOre = oreAmount * (weights[index] / MathF.Max(weightTotal, 0.001f)); deposits.Add(new ResourceDepositRuntime { Id = $"{nodeId}-deposit-{index + 1}", NodeId = nodeId, AnchorId = nodeId, Position = localPosition, OreRemaining = maxOre, MaxOre = maxOre, }); } return deposits; } private static float Hash01(string systemId, string nodeId, string salt) { unchecked { var hash = 17; foreach (var character in systemId) { hash = (hash * 31) + character; } foreach (var character in nodeId) { hash = (hash * 31) + character; } foreach (var character in salt) { hash = (hash * 31) + character; } return (hash & 0x7fffffff) / (float)int.MaxValue; } } private static Vector3 ComputePlanetPosition(PlanetDefinition planet) { var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius); return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm); } private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon) { var angle = DegreesToRadians(moon.OrbitPhaseAtEpoch); var local = new Vector3(MathF.Cos(angle) * moon.OrbitRadius, 0f, MathF.Sin(angle) * moon.OrbitRadius); return Add(planetPosition, local); } internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection anchors) { var nearestAnchor = anchors .Where(anchor => anchor.SystemId == systemId) .OrderBy(anchor => anchor.Position.DistanceTo(position)) .FirstOrDefault(); var localPosition = nearestAnchor is null ? position : position.Subtract(nearestAnchor.Position); return new ShipSpatialStateRuntime { CurrentSystemId = systemId, SpaceLayer = SpaceLayerKind.LocalSpace, CurrentAnchorId = nearestAnchor?.Id, LocalPosition = localPosition, SystemPosition = position, MovementRegime = MovementRegimeKind.LocalFlight, }; } } public sealed record ScenarioSpatialLayout( IReadOnlyDictionary SystemGraphs, List Anchors, List Celestials, List Nodes); public sealed record SystemSpatialGraph( string SystemId, List Celestials, Dictionary> LagrangeNodesByPlanetIndex); internal sealed record LagrangePointPlacement(string Designation, Vector3 Position); internal sealed record StationPlacement(AnchorRuntime Anchor, CelestialRuntime? Celestial, Vector3 Position);