using System.Text.Json; using SpaceGame.Api.Shared.Runtime; using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; internal sealed class DataCatalogLoader(string dataRoot) { private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, }; internal ScenarioCatalog LoadCatalog() { var authoredSystems = Read>("systems.json"); var scenario = Read("scenario.json"); var modules = NormalizeModules(Read>("modules.json")); var ships = Read>("ships.json"); var items = Read>("items.json"); var balance = Read("balance.json"); var recipes = BuildRecipes(items, ships, modules); var moduleRecipes = BuildModuleRecipes(modules); var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); return new ScenarioCatalog( authoredSystems, scenario, balance, modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal), ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal), moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal), productionGraph); } internal 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 { SystemId = ResolveSystemId(station.SystemId), Label = station.Label, Color = station.Color, Objective = station.Objective, StartingModules = station.StartingModules.ToList(), 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, StartingInventory = new Dictionary(formation.StartingInventory, StringComparer.Ordinal), }) .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 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 List BuildModuleRecipes(IEnumerable modules) => modules .Where(module => module.Construction is not null || module.Production.Count > 0) .Select(module => new ModuleRecipeDefinition { ModuleId = module.Id, Duration = module.Construction?.ProductionTime ?? module.Production[0].Time, Inputs = (module.Construction?.Requirements ?? module.Production[0].Wares) .Select(input => new RecipeInputDefinition { ItemId = input.ItemId, Amount = input.Amount, }) .ToList(), }) .ToList(); private static List BuildRecipes(IEnumerable items, IEnumerable ships, IReadOnlyCollection modules) { var recipes = new List(); var preferredProducerByItemId = modules .Where(module => module.Products.Count > 0) .GroupBy(module => module.Products[0], StringComparer.Ordinal) .ToDictionary( group => group.Key, group => group.OrderBy(module => module.Id, StringComparer.Ordinal).First().Id, StringComparer.Ordinal); foreach (var item in items) { if (item.Production.Count > 0) { foreach (var production in item.Production) { recipes.Add(new RecipeDefinition { Id = $"{item.Id}-{production.Method}-production", Label = production.Name == "Universal" ? item.Name : $"{item.Name} ({production.Name})", FacilityCategory = InferFacilityCategory(item), Duration = production.Time, Priority = InferRecipePriority(item), RequiredModules = InferRequiredModules(item, preferredProducerByItemId), Inputs = production.Wares .Select(input => new RecipeInputDefinition { ItemId = input.ItemId, Amount = input.Amount, }) .ToList(), Outputs = [ new RecipeOutputDefinition { ItemId = item.Id, Amount = production.Amount, }, ], }); } continue; } if (item.Construction is null) { continue; } recipes.Add(new RecipeDefinition { Id = item.Construction.RecipeId ?? $"{item.Id}-production", Label = item.Name, FacilityCategory = item.Construction.FacilityCategory, Duration = item.Construction.CycleTime, Priority = item.Construction.Priority, RequiredModules = item.Construction.RequiredModules.ToList(), Inputs = item.Construction.Requirements .Select(input => new RecipeInputDefinition { ItemId = input.ItemId, Amount = input.Amount, }) .ToList(), Outputs = [ new RecipeOutputDefinition { ItemId = item.Id, Amount = item.Construction.BatchSize, }, ], }); } foreach (var ship in ships) { if (ship.Construction is null) { continue; } recipes.Add(new RecipeDefinition { Id = ship.Construction.RecipeId ?? $"{ship.Id}-construction", Label = $"{ship.Label} Construction", FacilityCategory = ship.Construction.FacilityCategory, Duration = ship.Construction.CycleTime, Priority = ship.Construction.Priority, RequiredModules = ship.Construction.RequiredModules.ToList(), Inputs = ship.Construction.Requirements .Select(input => new RecipeInputDefinition { ItemId = input.ItemId, Amount = input.Amount, }) .ToList(), ShipOutputId = ship.Id, }); } return recipes; } private static string InferFacilityCategory(ItemDefinition item) => item.Group switch { "agricultural" or "food" or "pharmaceutical" or "water" => "farm", _ => "station", }; private static List InferRequiredModules(ItemDefinition item, IReadOnlyDictionary preferredProducerByItemId) { if (preferredProducerByItemId.TryGetValue(item.Id, out var moduleId)) { return [moduleId]; } return []; } private static int InferRecipePriority(ItemDefinition item) => item.Group switch { "energy" => 140, "water" => 130, "food" => 120, "agricultural" => 110, "refined" => 100, "hightech" => 90, "shiptech" => 80, "pharmaceutical" => 70, _ => 60, }; private static List NormalizeModules(List modules) { for (var index = 0; index < modules.Count; index += 1) { var module = modules[index]; try { module.ModuleType = module.Type.ToModuleType(); } catch (ArgumentOutOfRangeException exception) { throw new InvalidOperationException($"Module '{module.Id}' has unsupported type '{module.Type}'.", exception); } module.Type = module.ModuleType.ToDataValue(); if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product)) { module.Products = [module.Product]; } if (string.IsNullOrWhiteSpace(module.ProductionMode)) { module.ProductionMode = module.ModuleType == ModuleType.BuildModule ? "commanded" : "passive"; } if (module.WorkforceNeeded <= 0f) { module.WorkforceNeeded = module.WorkForce?.Max ?? 0f; } modules[index] = CreateSpecializedModuleDefinition(module); } return modules; } private static ModuleDefinition CreateSpecializedModuleDefinition(ModuleDefinition module) { if (module.ModuleType == ModuleType.Storage) { if (module.Cargo is null) { throw new InvalidOperationException($"Storage module '{module.Id}' is missing cargo metadata."); } try { return new StorageModuleDefinition(module, module.Cargo.Type.ToStorageKind(), module.Cargo.Max); } catch (ArgumentOutOfRangeException exception) { throw new InvalidOperationException($"Storage module '{module.Id}' has unsupported cargo type '{module.Cargo.Type}'.", exception); } } if (module.Products.Count > 0) { return new ProductionModuleDefinition(module); } return module; } } internal sealed record ScenarioCatalog( List AuthoredSystems, ScenarioDefinition Scenario, BalanceDefinition Balance, IReadOnlyDictionary ModuleDefinitions, IReadOnlyDictionary ShipDefinitions, IReadOnlyDictionary ItemDefinitions, IReadOnlyDictionary Recipes, IReadOnlyDictionary ModuleRecipes, ProductionGraph ProductionGraph);