Adds an `.editorconfig` file with C# and project-specific conventions. Applies consistent indentation and formatting across backend handlers, runtime models, and AI services.
306 lines
11 KiB
C#
306 lines
11 KiB
C#
using System.Text.Json;
|
|
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<List<SolarSystemDefinition>>("systems.json");
|
|
var scenario = Read<ScenarioDefinition>("scenario.json");
|
|
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
|
|
var ships = Read<List<ShipDefinition>>("ships.json");
|
|
var items = NormalizeItems(Read<List<ItemDefinition>>("items.json"));
|
|
var balance = Read<BalanceDefinition>("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<string> 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<string, float>(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<T>(string fileName)
|
|
{
|
|
var path = Path.Combine(dataRoot, fileName);
|
|
var json = File.ReadAllText(path);
|
|
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
|
|
?? throw new InvalidOperationException($"Unable to read {fileName}.");
|
|
}
|
|
|
|
private static List<ModuleRecipeDefinition> BuildModuleRecipes(IEnumerable<ModuleDefinition> 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<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
|
|
{
|
|
var recipes = new List<RecipeDefinition>();
|
|
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<string> InferRequiredModules(ItemDefinition item, IReadOnlyDictionary<string, string> 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<ItemDefinition> NormalizeItems(List<ItemDefinition> items)
|
|
{
|
|
foreach (var item in items)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(item.Type))
|
|
{
|
|
item.Type = string.IsNullOrWhiteSpace(item.Group) ? "material" : item.Group;
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
|
|
{
|
|
foreach (var module in modules)
|
|
{
|
|
if (module.Products.Count == 0 && !string.IsNullOrWhiteSpace(module.Product))
|
|
{
|
|
module.Products = [module.Product];
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(module.ProductionMode))
|
|
{
|
|
module.ProductionMode = string.Equals(module.Type, "buildmodule", StringComparison.Ordinal)
|
|
? "commanded"
|
|
: "passive";
|
|
}
|
|
|
|
if (module.WorkforceNeeded <= 0f)
|
|
{
|
|
module.WorkforceNeeded = module.WorkForce?.Max ?? 0f;
|
|
}
|
|
}
|
|
|
|
return modules;
|
|
}
|
|
}
|
|
|
|
internal sealed record ScenarioCatalog(
|
|
List<SolarSystemDefinition> AuthoredSystems,
|
|
ScenarioDefinition Scenario,
|
|
BalanceDefinition Balance,
|
|
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions,
|
|
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
|
|
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
|
|
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
|
|
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
|
|
ProductionGraph ProductionGraph);
|