Refactor world bootstrap and allow empty startup worlds

This commit is contained in:
2026-03-29 13:22:48 -04:00
parent 640e147ea8
commit 0bb72bee35
79 changed files with 173146 additions and 9235 deletions

View File

@@ -0,0 +1,14 @@
namespace SpaceGame.Api.Universe.Bootstrap;
public interface IStaticDataProvider
{
IReadOnlyList<SolarSystemDefinition> KnownSystems { get; }
IReadOnlyDictionary<string, RaceDefinition> RaceDefinitions { get; }
IReadOnlyDictionary<string, FactionDefinition> FactionDefinitions { get; }
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions { get; }
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions { get; }
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions { get; }
IReadOnlyDictionary<string, RecipeDefinition> Recipes { get; }
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; }
ProductionGraph ProductionGraph { get; }
}

View File

@@ -1,9 +0,0 @@
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed record StaticDataCatalog(
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions,
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
ProductionGraph ProductionGraph);

View File

@@ -2,5 +2,5 @@ namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class StaticDataOptions
{
public required string DataRoot { get; init; }
public required string DataRoot { get; set; }
}

View File

@@ -4,34 +4,59 @@ using SpaceGame.Api.Shared.Runtime;
namespace SpaceGame.Api.Universe.Bootstrap;
internal sealed class StaticDataLoader(IOptions<StaticDataOptions> staticDataOptions)
public sealed class StaticDataProvider : IStaticDataProvider
{
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
internal StaticDataCatalog Load()
public StaticDataProvider(IOptions<StaticDataOptions> staticDataOptions)
{
_dataRoot = staticDataOptions.Value.DataRoot;
var knownSystems = Read<List<SolarSystemDefinition>>("systems.json");
var races = Read<List<RaceDefinition>>("races.json");
var factions = Read<List<FactionDefinition>>("factions.json");
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json");
var items = Read<List<ItemDefinition>>("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);
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<SolarSystemDefinition> KnownSystems { get; }
public IReadOnlyDictionary<string, RaceDefinition> RaceDefinitions { get; }
public IReadOnlyDictionary<string, FactionDefinition> FactionDefinitions { get; }
public IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions { get; }
public IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions { get; }
public IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions { get; }
public IReadOnlyDictionary<string, RecipeDefinition> Recipes { get; }
public IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; }
public ProductionGraph ProductionGraph { get; }
private T Read<T>(string fileName)
{
var path = Path.Combine(staticDataOptions.Value.DataRoot, 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}.");
@@ -133,28 +158,26 @@ internal sealed class StaticDataLoader(IOptions<StaticDataOptions> staticDataOpt
foreach (var ship in ships)
{
if (ship.Construction is null)
foreach (var production in ship.Production)
{
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
recipes.Add(new RecipeDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
ShipOutputId = ship.Id,
});
Id = $"{ship.Id}-{production.Method}-construction",
Label = $"{ship.Label} 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;
@@ -191,6 +214,25 @@ internal sealed class StaticDataLoader(IOptions<StaticDataOptions> staticDataOpt
_ => 60,
};
private static List<string> 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) =>
ship.Kind switch
{
"military" => 170,
"construction" => 140,
"transport" => 120,
"mining" => 110,
_ => 100,
};
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
{
for (var index = 0; index < modules.Count; index += 1)

View File

@@ -1,20 +0,0 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class SystemTemplateLoader(IOptions<StaticDataOptions> staticDataOptions)
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
internal List<SolarSystemDefinition> Load()
{
var path = Path.Combine(staticDataOptions.Value.DataRoot, "systems.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<SolarSystemDefinition>>(json, _jsonOptions)
?? throw new InvalidOperationException("Unable to read systems.json.");
}
}

View File

@@ -1,48 +0,0 @@
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Scenario;
using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class WorldBootstrapper
{
private readonly BalanceOptions _defaultBalance;
private readonly WorldGenerationOptions _defaultWorldGeneration;
private readonly StaticDataCatalog _staticData;
private readonly ScenarioLoader _scenarioLoader;
private readonly SystemTemplateLoader _systemTemplateLoader;
private readonly WorldBuilder _worldBuilder;
public WorldBootstrapper(
StaticDataCatalog staticData,
ScenarioLoader scenarioLoader,
SystemTemplateLoader systemTemplateLoader,
WorldBuilder worldBuilder,
IOptions<BalanceOptions> defaultBalanceOptions,
IOptions<WorldGenerationOptions> defaultWorldGenerationOptions)
{
_defaultBalance = defaultBalanceOptions.Value;
_defaultWorldGeneration = defaultWorldGenerationOptions.Value;
_staticData = staticData;
_scenarioLoader = scenarioLoader;
_systemTemplateLoader = systemTemplateLoader;
_worldBuilder = worldBuilder;
}
public SimulationWorld Bootstrap()
{
var scenario = _scenarioLoader.Load();
var gameStartOptions = scenario?.GameStartOptions ?? new GameStartOptionsDefinition
{
Seed = 1,
WorldGeneration = _defaultWorldGeneration,
};
return _worldBuilder.Build(
_staticData,
_defaultBalance,
_systemTemplateLoader.Load(),
gameStartOptions,
scenario);
}
}