using System.Text.Json; using SpaceGame.Simulation.Api.Data; namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class ScenarioLoader { private const string DefaultFactionId = "sol-dominion"; private const string UnclaimedFactionId = "unclaimed"; private const int WorldSeed = 1; private const float MinimumFactionCredits = 0f; private const float MinimumRefineryOre = 0f; private const float MinimumRefineryStock = 0f; private const float MinimumShipyardStock = 0f; private const float MinimumSystemSeparation = 3200f; private const float StarBubbleRadiusPadding = 40f; private const float PlanetBubbleRadiusPadding = 80f; private const float MoonBubbleRadiusPadding = 40f; private const float LagrangeBubbleRadius = 150f; private const float ResourceBubbleRadius = 120f; private static readonly string[] GeneratedSystemNames = [ "Aquila Verge", "Orion Fold", "Draco Span", "Lyra Shoal", "Cygnus March", "Vela Crossing", "Carina Wake", "Phoenix Rest", "Hydra Loom", "Cassio Reach", "Lupus Chain", "Pavo Line", "Serpens Rise", "Cetus Hollow", "Delphin Crown", "Volans Drift", "Ara Bastion", "Indus Veil", "Pyxis Trace", "Lacerta Bloom", "Columba Shroud", "Dorado Expanse", "Reticulum Run", "Norma Edge", "Crux Horizon", "Sagitta Corridor", "Monoceros Deep", "Eridan Spur", "Centauri Shelf", "Antlia Reach", "Horologium Gate", "Telescopium Strand", ]; private static readonly StarProfile[] StarProfiles = [ new("main-sequence", "#ffd27a", "#ffb14a", 54f, 1), new("blue-white", "#9dc6ff", "#66a0ff", 50f, 1), new("white-dwarf", "#f1f5ff", "#b8caff", 26f, 1), new("brown-dwarf", "#b97d56", "#8a5438", 20f, 1), new("neutron-star", "#d9ebff", "#7ab4ff", 18f, 1), new("binary-main-sequence", "#ffe09f", "#ffbe6b", 64f, 2), new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 34f, 2), ]; private static readonly PlanetProfile[] PlanetProfiles = [ new("barren", "sphere", "#bca48f", 18f, 38f, 0, false), new("terrestrial", "sphere", "#58a36c", 24f, 46f, 1, false), new("oceanic", "sphere", "#4f84c4", 26f, 44f, 2, false), new("desert", "sphere", "#d4a373", 22f, 42f, 0, false), new("ice", "sphere", "#c8e4ff", 24f, 40f, 1, false), new("gas-giant", "oblate", "#d9b06f", 52f, 86f, 8, true), new("ice-giant", "oblate", "#8fc0d8", 44f, 72f, 5, true), new("lava", "sphere", "#db6846", 20f, 36f, 0, false), ]; private readonly string _dataRoot; private readonly WorldGenerationOptions _worldGeneration; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, }; public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null) { _dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data")); _worldGeneration = worldGeneration ?? new WorldGenerationOptions(); } public SimulationWorld Load() { var authoredSystems = Read>("systems.json"); var systems = ExpandSystems( InjectSpecialSystems(authoredSystems, _worldGeneration.IncludeSolSystem), _worldGeneration.TargetSystemCount); var scenario = NormalizeScenarioToAvailableSystems( Read("scenario.json"), systems.Select((system) => system.Id).ToList()); var ships = Read>("ships.json"); var constructibles = Read>("constructibles.json"); var items = Read>("items.json"); var recipes = Read>("recipes.json"); var moduleRecipes = Read>("module-recipes.json"); var balance = Read("balance.json"); var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var recipeDefinitions = recipes.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal); var systemRuntimes = systems .Select((definition) => new SystemRuntime { Definition = definition, Position = ToVector(definition.Position), }) .ToList(); var systemsById = systemRuntimes.ToDictionary((system) => system.Definition.Id, StringComparer.Ordinal); var systemGraphs = systemRuntimes.ToDictionary( (system) => system.Definition.Id, (system) => BuildSystemSpatialGraph(system), StringComparer.Ordinal); var nodes = new List(); var spatialNodes = new List(); var localBubbles = new List(); var nodeIdCounter = 0; foreach (var graph in systemGraphs.Values) { spatialNodes.AddRange(graph.Nodes); localBubbles.AddRange(graph.Bubbles); } foreach (var system in systemRuntimes) { var systemGraph = systemGraphs[system.Definition.Id]; foreach (var node in system.Definition.ResourceNodes) { var anchorNode = ResolveResourceNodeAnchor(systemGraph, node); var resourceNode = new ResourceNodeRuntime { Id = $"node-{++nodeIdCounter}", SystemId = system.Definition.Id, Position = ComputeResourceNodePosition(anchorNode, node, balance.YPlane), SourceKind = node.SourceKind, ItemId = node.ItemId, AnchorNodeId = anchorNode?.Id, OrbitRadius = node.RadiusOffset, OrbitPhase = node.Angle, OrbitInclination = DegreesToRadians(node.InclinationDegrees), OreRemaining = node.OreAmount, MaxOre = node.OreAmount, }; nodes.Add(resourceNode); var bubbleId = $"bubble-{resourceNode.Id}"; spatialNodes.Add(new NodeRuntime { Id = resourceNode.Id, SystemId = resourceNode.SystemId, Kind = SpatialNodeKind.ResourceSite, Position = resourceNode.Position, BubbleId = bubbleId, ParentNodeId = anchorNode?.Id, }); localBubbles.Add(new LocalBubbleRuntime { Id = bubbleId, NodeId = resourceNode.Id, SystemId = resourceNode.SystemId, Radius = ResourceBubbleRadius, }); } } var stations = new List(); var stationIdCounter = 0; foreach (var plan in scenario.InitialStations) { if (!constructibleDefinitions.TryGetValue(plan.ConstructibleId, out var definition) || !systemsById.TryGetValue(plan.SystemId, out var system)) { continue; } var placement = ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], spatialNodes); var station = new StationRuntime { Id = $"station-{++stationIdCounter}", SystemId = system.Definition.Id, Definition = definition, Position = placement.Position, FactionId = plan.FactionId ?? DefaultFactionId, }; var stationNodeId = $"node-{station.Id}"; var stationBubbleId = $"bubble-{station.Id}"; station.NodeId = stationNodeId; station.BubbleId = stationBubbleId; station.AnchorNodeId = placement.AnchorNode.Id; stations.Add(station); spatialNodes.Add(new NodeRuntime { Id = stationNodeId, SystemId = station.SystemId, Kind = SpatialNodeKind.Station, Position = station.Position, BubbleId = stationBubbleId, ParentNodeId = placement.AnchorNode.Id, OccupyingStructureId = station.Id, }); localBubbles.Add(new LocalBubbleRuntime { Id = stationBubbleId, NodeId = stationNodeId, SystemId = station.SystemId, Radius = MathF.Max(160f, definition.Radius + 60f), }); localBubbles[^1].OccupantStationIds.Add(station.Id); placement.AnchorNode.OccupyingStructureId = station.Id; foreach (var moduleId in definition.Modules) { stations[^1].InstalledModules.Add(moduleId); } } foreach (var station in stations) { InitializeStationPopulation(station); station.Inventory["fuel"] = 240f; station.Inventory["refined-metals"] = 120f; if (station.Population > 0f) { station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f); } } var refinery = stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank") && station.SystemId == scenario.MiningDefaults.RefinerySystemId) ?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank")); var patrolRoutes = scenario.PatrolRoutes .GroupBy((route) => route.SystemId, StringComparer.Ordinal) .ToDictionary( (group) => group.Key, (group) => group .SelectMany((route) => route.Points) .Select((point) => NormalizeScenarioPoint(systemsById[group.Key], point)) .ToList(), StringComparer.Ordinal); var shipsRuntime = new List(); var shipIdCounter = 0; foreach (var formation in scenario.ShipFormations) { if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition)) { continue; } for (var index = 0; index < formation.Count; index += 1) { var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset); shipsRuntime.Add(new ShipRuntime { Id = $"ship-{++shipIdCounter}", SystemId = formation.SystemId, Definition = definition, FactionId = formation.FactionId ?? DefaultFactionId, Position = position, TargetPosition = position, SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes), DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery), ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending }, Health = definition.MaxHealth, }); shipsRuntime[^1].Inventory["gas"] = definition.Id switch { _ => 0f, }; shipsRuntime[^1].Inventory.Remove("gas"); shipsRuntime[^1].Inventory["fuel"] = definition.Id switch { "constructor" => 90f, "miner" => 90f, "gas-miner" => 90f, _ => 120f, }; } } var factions = CreateFactions(stations, shipsRuntime); BootstrapFactionEconomy(factions, stations); var policies = CreatePolicies(factions); var commanders = CreateCommanders(factions, stations, shipsRuntime); var now = DateTimeOffset.UtcNow; var claims = CreateClaims(stations, spatialNodes, now); var (constructionSites, marketOrders) = CreateConstructionSites(stations, claims, spatialNodes, moduleRecipeDefinitions); return new SimulationWorld { Label = "Split Viewer / Simulation World", Seed = WorldSeed, Balance = balance, Systems = systemRuntimes, Nodes = nodes, SpatialNodes = spatialNodes, LocalBubbles = localBubbles, Stations = stations, Ships = shipsRuntime, Factions = factions, Commanders = commanders, Claims = claims, ConstructionSites = constructionSites, MarketOrders = marketOrders, Policies = policies, ShipDefinitions = shipDefinitions, ItemDefinitions = itemDefinitions, ModuleRecipes = moduleRecipeDefinitions, Recipes = recipeDefinitions, OrbitalTimeSeconds = WorldSeed * 97d, GeneratedAtUtc = DateTimeOffset.UtcNow, }; } private T Read(string fileName) { var path = Path.Combine(_dataRoot, fileName); var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, _jsonOptions) ?? throw new InvalidOperationException($"Unable to read {fileName}."); } private static ScenarioDefinition NormalizeScenarioToAvailableSystems( ScenarioDefinition scenario, IReadOnlyList availableSystemIds) { if (availableSystemIds.Count == 0) { return scenario; } var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal) ? "sol" : availableSystemIds[0]; string ResolveSystemId(string systemId) => availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId; return new ScenarioDefinition { InitialStations = scenario.InitialStations .Select((station) => new InitialStationDefinition { ConstructibleId = station.ConstructibleId, SystemId = ResolveSystemId(station.SystemId), FactionId = station.FactionId, PlanetIndex = station.PlanetIndex, LagrangeSide = station.LagrangeSide, Position = station.Position?.ToArray(), }) .ToList(), ShipFormations = scenario.ShipFormations .Select((formation) => new ShipFormationDefinition { ShipId = formation.ShipId, Count = formation.Count, Center = formation.Center.ToArray(), SystemId = ResolveSystemId(formation.SystemId), FactionId = formation.FactionId, }) .ToList(), PatrolRoutes = scenario.PatrolRoutes .Select((route) => new PatrolRouteDefinition { SystemId = ResolveSystemId(route.SystemId), Points = route.Points.Select((point) => point.ToArray()).ToList(), }) .ToList(), MiningDefaults = new MiningDefaultsDefinition { NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId), RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId), }, }; } private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) { var raw = ToVector(values); var relativeToSystem = new Vector3( raw.X - system.Position.X, raw.Y - system.Position.Y, raw.Z - system.Position.Z); return relativeToSystem.LengthSquared() < raw.LengthSquared() ? relativeToSystem : raw; } private static bool HasModules(ConstructibleDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); private static bool HasInstalledModules(StationRuntime station, params string[] modules) => modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)); private static bool HasModules(ShipDefinition definition, params string[] modules) => modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); private static int CountModules(IEnumerable modules, string moduleId) => modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal)); 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 Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale); private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f); private static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback) { var length = MathF.Sqrt(vector.LengthSquared()); if (length <= 0.0001f) { return fallback; } return vector.Divide(length); } }