using System.Text.Json; using Microsoft.Extensions.Options; using SpaceGame.Api.Shared.Runtime; namespace SpaceGame.Api.Universe.Bootstrap; internal sealed class StaticDataLoader(IOptions staticDataOptions) { private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, }; internal StaticDataCatalog Load() { var modules = NormalizeModules(Read>("modules.json")); var ships = Read>("ships.json"); var items = Read>("items.json"); var recipes = BuildRecipes(items, ships, modules); var moduleRecipes = BuildModuleRecipes(modules); var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); return new StaticDataCatalog( 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); } private T Read(string fileName) { var path = Path.Combine(staticDataOptions.Value.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.BuildRecipes.Count > 0) .Select(module => new ModuleRecipeDefinition { ModuleId = module.Id, Duration = module.BuildRecipes[0].Time, Inputs = module.BuildRecipes[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.ProductItemIds.Count > 0) .GroupBy(module => module.ProductItemIds[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(); 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.ModuleType == ModuleType.Habitation) { return new HabitationModuleDefinition(module, module.SerializedWorkforce?.SupportedPopulation ?? 0f); } if (module.ModuleType == ModuleType.BuildModule) { return new BuildModuleDefinition(module, module.SerializedWorkforce?.RequiredWorkforce ?? 0f); } if (module.ModuleType == ModuleType.Production) { return new ProductionModuleDefinition(module, module.SerializedWorkforce?.RequiredWorkforce ?? 0f); } return module; } }