using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Extensions.Options; using SpaceGame.Api.Shared.Runtime; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; namespace SpaceGame.Api.Universe.Bootstrap; public sealed class StaticDataProvider : IStaticDataProvider { private const string MilitaryShipCategory = "military"; private const string ConstructionShipCategory = "construction"; private const string TransportShipCategory = "transport"; private const string MiningShipCategory = "mining"; private readonly string _dataRoot; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() }, }; public StaticDataProvider(IOptions staticDataOptions) { _dataRoot = staticDataOptions.Value.DataRoot; var knownSystems = Read>("systems.json"); var races = Read>("races.json"); var factions = Read>("factions.json"); 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); KnownSystems = knownSystems; RaceDefinitions = races.ToDictionary(definition => definition.Id, StringComparer.Ordinal); FactionDefinitions = factions.ToDictionary(definition => definition.Id, StringComparer.Ordinal); ModuleDefinitions = modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal); ShipDefinitions = ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal); ItemDefinitions = items.ToDictionary(definition => definition.Id, StringComparer.Ordinal); Recipes = recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal); ModuleRecipes = moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal); ProductionGraph = ProductionGraphBuilder.Build(items, recipes, modules); } public IReadOnlyList KnownSystems { get; } public IReadOnlyDictionary RaceDefinitions { get; } public IReadOnlyDictionary FactionDefinitions { get; } public IReadOnlyDictionary ModuleDefinitions { get; } public IReadOnlyDictionary ShipDefinitions { get; } public IReadOnlyDictionary ItemDefinitions { get; } public IReadOnlyDictionary Recipes { get; } public IReadOnlyDictionary ModuleRecipes { get; } public ProductionGraph ProductionGraph { get; } 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.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) { foreach (var production in ship.Production) { recipes.Add(new RecipeDefinition { Id = $"{ship.Id}-{production.Method}-construction", Label = $"{ship.Name} Construction", FacilityCategory = "shipyard", Duration = production.Time, Priority = InferShipRecipePriority(ship), RequiredModules = InferShipBuildModules(ship), Inputs = production.Wares .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 InferShipBuildModules(ShipDefinition ship) => ship.Size switch { "extrasmall" or "small" or "medium" => ["module_gen_build_dockarea_m_01"], "large" => ["module_gen_build_l_01"], "extralarge" => ["module_gen_build_xl_01"], _ => ["module_gen_build_dockarea_m_01"], }; private static int InferShipRecipePriority(ShipDefinition ship) => GetShipCategory(ship) switch { MilitaryShipCategory => 170, ConstructionShipCategory => 140, TransportShipCategory => 120, MiningShipCategory => 110, _ => 100, }; 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; } }