to rename

This commit is contained in:
2026-03-28 11:32:28 -04:00
parent 04d182e93f
commit 640e147ea8
25 changed files with 367 additions and 224 deletions

View File

@@ -1,327 +0,0 @@
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<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 = 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.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<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships, IReadOnlyCollection<ModuleDefinition> modules)
{
var recipes = new List<RecipeDefinition>();
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<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<ModuleDefinition> NormalizeModules(List<ModuleDefinition> 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;
}
}
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);

View File

@@ -6,7 +6,6 @@ namespace SpaceGame.Api.Universe.Scenario;
internal static class LoaderSupport
{
internal const string DefaultFactionId = "sol-dominion";
internal const int WorldSeed = 1;
internal const float MinimumFactionCredits = 0f;
internal const float MinimumRefineryOre = 0f;
internal const float MinimumRefineryStock = 0f;

View File

@@ -1,26 +1,27 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class ScenarioLoader
public sealed class ScenarioLoader(IOptions<StaticDataOptions> staticDataOptions)
{
private readonly WorldBuilder _worldBuilder;
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
private readonly JsonSerializerOptions _jsonOptions = new()
{
var generationOptions = worldGeneration ?? new WorldGenerationOptions();
var dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
var dataLoader = new DataCatalogLoader(dataRoot);
var generationService = new SystemGenerationService();
var spatialBuilder = new SpatialBuilder();
var seedingService = new WorldSeedingService();
PropertyNameCaseInsensitive = true,
};
_worldBuilder = new WorldBuilder(
generationOptions,
dataLoader,
generationService,
spatialBuilder,
seedingService);
public ScenarioDefinition? Load()
{
var scenarioPath = Path.Combine(staticDataOptions.Value.DataRoot, "scenario.json");
if (!File.Exists(scenarioPath))
{
return null;
}
var json = File.ReadAllText(scenarioPath);
return JsonSerializer.Deserialize<ScenarioDefinition>(json, _jsonOptions)
?? throw new InvalidOperationException("Unable to read scenario.json.");
}
public SimulationWorld Load() => _worldBuilder.Build();
}

View File

@@ -2,9 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
internal sealed class SpatialBuilder
public sealed class SpatialBuilder
{
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance)
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceOptions balance)
{
var systemGraphs = systems.ToDictionary(
system => system.Definition.Id,

View File

@@ -2,12 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
internal sealed class SystemGenerationService
public sealed class SystemGenerationService
{
private const string SolSystemId = "sol";
private const string DevelopmentCompanionSystemId = "helios";
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
internal List<SolarSystemDefinition> PrepareAuthoredSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems
.Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index))
.ToList();
@@ -71,8 +68,10 @@ internal sealed class SystemGenerationService
}
}
AddById(SolSystemId);
AddById(DevelopmentCompanionSystemId);
foreach (var preferredSystemId in SystemSelectionPolicy.PreferredSystemIds)
{
AddById(preferredSystemId);
}
foreach (var system in systems)
{

View File

@@ -0,0 +1,23 @@
namespace SpaceGame.Api.Universe.Scenario;
internal static class SystemSelectionPolicy
{
internal static readonly string[] PreferredSystemIds =
[
"sol",
"helios",
];
internal static string SelectFallbackSystemId(IReadOnlyList<string> availableSystemIds)
{
foreach (var preferredSystemId in PreferredSystemIds)
{
if (availableSystemIds.Contains(preferredSystemId, StringComparer.Ordinal))
{
return preferredSystemId;
}
}
return availableSystemIds.FirstOrDefault() ?? string.Empty;
}
}

View File

@@ -1,23 +1,26 @@
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
using SpaceGame.Api.Universe.Bootstrap;
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Scenario;
internal sealed class WorldBuilder(
WorldGenerationOptions worldGeneration,
DataCatalogLoader dataLoader,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder,
WorldSeedingService seedingService)
public sealed class WorldBuilder(
StaticDataCatalog staticData,
IOptions<BalanceOptions> balance,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder,
WorldSeedingService seedingService)
{
internal SimulationWorld Build()
public SimulationWorld Build(
GameStartOptionsDefinition gameStartOptions,
ScenarioDefinition? scenarioDefinition)
{
var catalog = dataLoader.LoadCatalog();
var systems = generationService.ExpandSystems(
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
worldGeneration.TargetSystemCount);
generationService.PrepareAuthoredSystems(authoredSystems),
gameStartOptions.WorldGeneration.TargetSystemCount);
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
catalog.Scenario,
var scenario = NormalizeScenarioToAvailableSystems(
scenarioDefinition,
systems.Select(system => system.Id).ToList());
var systemRuntimes = systems
@@ -28,22 +31,22 @@ internal sealed class WorldBuilder(
})
.ToList();
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance);
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, balance.Value);
var stations = CreateStations(
scenario,
systemsById,
spatialLayout.SystemGraphs,
spatialLayout.Celestials,
catalog.ModuleDefinitions,
catalog.ItemDefinitions);
staticData.ModuleDefinitions,
staticData.ItemDefinitions);
seedingService.InitializeStationStockpiles(stations, catalog.ModuleDefinitions);
seedingService.InitializeStationStockpiles(stations, staticData.ModuleDefinitions);
var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, staticData.ShipDefinitions, patrolRoutes, stations, refinery);
if (worldGeneration.AiControllerFactionCount < int.MaxValue)
if (gameStartOptions.WorldGeneration.AiControllerFactionCount < int.MaxValue)
{
var aiFactionIds = stations
.Select(s => s.FactionId)
@@ -51,7 +54,7 @@ internal sealed class WorldBuilder(
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.Take(worldGeneration.AiControllerFactionCount)
.Take(gameStartOptions.WorldGeneration.AiControllerFactionCount)
.ToHashSet(StringComparer.Ordinal);
aiFactionIds.Add(DefaultFactionId);
stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
@@ -63,15 +66,14 @@ internal sealed class WorldBuilder(
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow;
var playerFaction = worldGeneration.GeneratePlayerFaction
var playerFaction = gameStartOptions.WorldGeneration.GeneratePlayerFaction
? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc)
: null;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var world = new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = catalog.Balance,
Seed = gameStartOptions.Seed,
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
@@ -86,13 +88,13 @@ internal sealed class WorldBuilder(
ConstructionSites = [],
MarketOrders = [],
Policies = policies,
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal),
ProductionGraph = catalog.ProductionGraph,
OrbitalTimeSeconds = WorldSeed * 97d,
ShipDefinitions = new Dictionary<string, ShipDefinition>(staticData.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(staticData.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(staticData.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(staticData.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(staticData.Recipes, StringComparer.Ordinal),
ProductionGraph = staticData.ProductionGraph,
OrbitalTimeSeconds = gameStartOptions.Seed * 97d,
GeneratedAtUtc = nowUtc,
};
@@ -105,6 +107,79 @@ internal sealed class WorldBuilder(
return world;
}
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition? scenario,
IReadOnlyList<string> availableSystemIds)
{
var fallbackSystemId = SystemSelectionPolicy.SelectFallbackSystemId(availableSystemIds);
if (scenario is null)
{
return new ScenarioDefinition
{
GameStartOptions = new GameStartOptionsDefinition(),
InitialStations = [],
ShipFormations = [],
PatrolRoutes = [],
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = fallbackSystemId,
RefinerySystemId = fallbackSystemId,
},
};
}
if (availableSystemIds.Count == 0)
{
return scenario;
}
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
GameStartOptions = scenario.GameStartOptions,
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 static List<StationRuntime> CreateStations(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
@@ -248,11 +323,10 @@ internal sealed class WorldBuilder(
StringComparer.Ordinal);
}
private static List<ShipRuntime> CreateShips(
private List<ShipRuntime> CreateShips(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials,
BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
@@ -270,7 +344,7 @@ internal sealed class WorldBuilder(
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var offset = new Vector3((index % 3) * 18f, balance.Value.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
ships.Add(new ShipRuntime

View File

@@ -2,7 +2,7 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
internal sealed class WorldSeedingService
public sealed class WorldSeedingService
{
internal List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,