refactor(backend): modularize scenario loader

This commit is contained in:
2026-03-19 17:42:53 -04:00
parent 8d2a810f6b
commit a281d37fb4
8 changed files with 915 additions and 810 deletions

View File

@@ -0,0 +1,302 @@
using System.Text.Json;
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
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);
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));
}
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,
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,
})
.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);

View File

@@ -0,0 +1,173 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation;
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;
internal const float MinimumShipyardStock = 0f;
internal const float MinimumSystemSeparation = 3.2f;
internal const float LocalSpaceRadius = 10_000f;
internal static readonly string[] GeneratedSystemNames =
[
"Aquila Verge",
"Orion Fold",
"Draco Span",
"Lyra Shoal",
"Cygnus March",
"Vela Crossing",
"Carina Wake",
"Phoenix Rest",
"Hydra Loom",
"Cassio Reach",
"Lupus Chain",
"Pavo Line",
"Serpens Rise",
"Cetus Hollow",
"Delphin Crown",
"Volans Drift",
"Ara Bastion",
"Indus Veil",
"Pyxis Trace",
"Lacerta Bloom",
"Columba Shroud",
"Dorado Expanse",
"Reticulum Run",
"Norma Edge",
"Crux Horizon",
"Sagitta Corridor",
"Monoceros Deep",
"Eridan Spur",
"Centauri Shelf",
"Antlia Reach",
"Horologium Gate",
"Telescopium Strand",
];
internal static readonly StarProfile[] StarProfiles =
[
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
];
internal static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
new("ice", "sphere", "#c8e4ff", 5800f, 0.32f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 45000f, 1.40f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 25000f, 1.00f, 5, true),
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
];
internal static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
internal static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
{
var raw = ToVector(values);
var relativeToSystem = new Vector3(
raw.X - system.Position.X,
raw.Y - system.Position.Y,
raw.Z - system.Position.Z);
return relativeToSystem.LengthSquared() < raw.LengthSquared()
? relativeToSystem
: raw;
}
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
}
station.Modules.Add(new StationModuleRuntime
{
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
ModuleId = moduleId,
Health = definition.Hull,
MaxHealth = definition.Hull,
});
station.Radius = GetStationRadius(moduleDefinitions, station);
}
internal static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
{
var totalArea = station.Modules
.Select(module => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
.Sum();
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
internal static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
internal static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
internal static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
internal static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
internal static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
internal static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
internal static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
{
var length = MathF.Sqrt(vector.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return vector.Divide(length);
}
}
internal sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize);
internal sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}

View File

@@ -0,0 +1,27 @@
using SpaceGame.Api.Simulation.Model;
namespace SpaceGame.Api.Simulation;
public sealed class ScenarioLoader
{
private readonly WorldBuilder _worldBuilder;
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{
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();
_worldBuilder = new WorldBuilder(
generationOptions,
dataLoader,
generationService,
spatialBuilder,
seedingService);
}
public SimulationWorld Load() => _worldBuilder.Build();
}

View File

@@ -1,9 +1,47 @@
using SpaceGame.Simulation.Api.Data; using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Simulation.Api.Simulation; namespace SpaceGame.Api.Simulation;
public sealed partial class ScenarioLoader internal sealed class SpatialBuilder
{ {
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance)
{
var systemGraphs = systems.ToDictionary(
system => system.Definition.Id,
BuildSystemSpatialGraph,
StringComparer.Ordinal);
var celestials = systemGraphs.Values.SelectMany(graph => graph.Celestials).ToList();
var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var system in systems)
{
var systemGraph = systemGraphs[system.Definition.Id];
foreach (var node in system.Definition.ResourceNodes)
{
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
nodes.Add(new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id,
OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
});
}
}
return new ScenarioSpatialLayout(systemGraphs, celestials, nodes);
}
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system) private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
{ {
var celestials = new List<CelestialRuntime>(); var celestials = new List<CelestialRuntime>();
@@ -96,9 +134,7 @@ public sealed partial class ScenarioLoader
return celestial; return celestial;
} }
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints( private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet)
Vector3 planetPosition,
PlanetDefinition planet)
{ {
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X); var tangential = new Vector3(-radial.Z, 0f, radial.X);
@@ -129,7 +165,6 @@ public sealed partial class ScenarioLoader
return MathF.Max(minimumOffset, hillLikeOffset); return MathF.Max(minimumOffset, hillLikeOffset);
} }
// The simulation does not track physical masses yet, so use a size/density proxy.
private static float EstimatePlanetMassRatio(PlanetDefinition planet) private static float EstimatePlanetMassRatio(PlanetDefinition planet)
{ {
var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f); var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f);
@@ -146,7 +181,7 @@ public sealed partial class ScenarioLoader
return earthMasses / 332_946f; return earthMasses / 332_946f;
} }
private static StationPlacement ResolveStationPlacement( internal static StationPlacement ResolveStationPlacement(
InitialStationDefinition plan, InitialStationDefinition plan,
SystemRuntime system, SystemRuntime system,
SystemSpatialGraph graph, SystemSpatialGraph graph,
@@ -166,19 +201,19 @@ public sealed partial class ScenarioLoader
{ {
var targetPosition = NormalizeScenarioPoint(system, plan.Position); var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredCelestial = existingCelestials var preferredCelestial = existingCelestials
.Where((c) => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) .Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy((c) => c.Position.DistanceTo(targetPosition)) .OrderBy(c => c.Position.DistanceTo(targetPosition))
.FirstOrDefault() .FirstOrDefault()
?? existingCelestials ?? existingCelestials
.Where((c) => c.SystemId == system.Definition.Id) .Where(c => c.SystemId == system.Definition.Id)
.OrderBy((c) => c.Position.DistanceTo(targetPosition)) .OrderBy(c => c.Position.DistanceTo(targetPosition))
.First(); .First();
return new StationPlacement(preferredCelestial, preferredCelestial.Position); return new StationPlacement(preferredCelestial, preferredCelestial.Position);
} }
var fallbackCelestial = graph.Celestials var fallbackCelestial = graph.Celestials
.FirstOrDefault((c) => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) .FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId))
?? graph.Celestials.First((c) => c.Kind == SpatialNodeKind.Planet); ?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet);
return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); return new StationPlacement(fallbackCelestial, fallbackCelestial.Position);
} }
@@ -199,11 +234,11 @@ public sealed partial class ScenarioLoader
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0) if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
{ {
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}"; var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
return graph.Celestials.FirstOrDefault((c) => c.Id == moonNodeId); return graph.Celestials.FirstOrDefault(c => c.Id == moonNodeId);
} }
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}"; var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
return graph.Celestials.FirstOrDefault((c) => c.Id == planetNodeId); return graph.Celestials.FirstOrDefault(c => c.Id == planetNodeId);
} }
private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane) private static Vector3 ComputeResourceNodePosition(CelestialRuntime? anchorCelestial, ResourceNodeDefinition definition, float yPlane)
@@ -226,9 +261,7 @@ public sealed partial class ScenarioLoader
{ {
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius); var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius);
var x = MathF.Cos(angle) * orbitRadiusKm; return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm);
var z = MathF.Sin(angle) * orbitRadiusKm;
return new Vector3(x, 0f, z);
} }
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon) private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon)
@@ -238,11 +271,11 @@ public sealed partial class ScenarioLoader
return Add(planetPosition, local); return Add(planetPosition, local);
} }
private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials) internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<CelestialRuntime> celestials)
{ {
var nearestCelestial = celestials var nearestCelestial = celestials
.Where((c) => c.SystemId == systemId) .Where(c => c.SystemId == systemId)
.OrderBy((c) => c.Position.DistanceTo(position)) .OrderBy(c => c.Position.DistanceTo(position))
.FirstOrDefault(); .FirstOrDefault();
return new ShipSpatialStateRuntime return new ShipSpatialStateRuntime
@@ -255,13 +288,18 @@ public sealed partial class ScenarioLoader
MovementRegime = MovementRegimeKinds.LocalFlight, MovementRegime = MovementRegimeKinds.LocalFlight,
}; };
} }
private sealed record SystemSpatialGraph(
string SystemId,
List<CelestialRuntime> Celestials,
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
private sealed record LagrangePointPlacement(string Designation, Vector3 Position);
private sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position);
} }
internal sealed record ScenarioSpatialLayout(
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
List<CelestialRuntime> Celestials,
List<ResourceNodeRuntime> Nodes);
internal sealed record SystemSpatialGraph(
string SystemId,
List<CelestialRuntime> Celestials,
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);
internal sealed record LagrangePointPlacement(string Designation, Vector3 Position);
internal sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position);

View File

@@ -1,21 +1,20 @@
using SpaceGame.Simulation.Api.Data; using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Simulation.Api.Simulation; namespace SpaceGame.Api.Simulation;
public sealed partial class ScenarioLoader internal sealed class SystemGenerationService
{ {
private const string SolSystemId = "sol"; private const string SolSystemId = "sol";
private const string DevelopmentCompanionSystemId = "helios"; private const string DevelopmentCompanionSystemId = "helios";
private static List<SolarSystemDefinition> InjectSpecialSystems( internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
IReadOnlyList<SolarSystemDefinition> authoredSystems) authoredSystems
{
return authoredSystems
.Select(CloneSystemDefinition) .Select(CloneSystemDefinition)
.ToList(); .ToList();
}
private static List<SolarSystemDefinition> ExpandSystems( internal List<SolarSystemDefinition> ExpandSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems, IReadOnlyList<SolarSystemDefinition> authoredSystems,
int targetSystemCount) int targetSystemCount)
{ {
@@ -39,10 +38,10 @@ public sealed partial class ScenarioLoader
} }
var existingIds = systems var existingIds = systems
.Select((system) => system.Id) .Select(system => system.Id)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions( var generatedPositions = BuildGalaxyPositions(
authoredSystems.Select((system) => ToVector(system.Position)).ToList(), authoredSystems.Select(system => ToVector(system.Position)).ToList(),
targetSystemCount - systems.Count); targetSystemCount - systems.Count);
for (var index = systems.Count; index < targetSystemCount; index += 1) for (var index = systems.Count; index < targetSystemCount; index += 1)
@@ -61,16 +60,14 @@ public sealed partial class ScenarioLoader
return systems; return systems;
} }
private static List<SolarSystemDefinition> TrimSystemsToTarget( private static List<SolarSystemDefinition> TrimSystemsToTarget(IReadOnlyList<SolarSystemDefinition> systems, int targetSystemCount)
IReadOnlyList<SolarSystemDefinition> systems,
int targetSystemCount)
{ {
var selected = new List<SolarSystemDefinition>(targetSystemCount); var selected = new List<SolarSystemDefinition>(targetSystemCount);
void AddById(string systemId) void AddById(string systemId)
{ {
var system = systems.FirstOrDefault((candidate) => string.Equals(candidate.Id, systemId, StringComparison.Ordinal)); var system = systems.FirstOrDefault(candidate => string.Equals(candidate.Id, systemId, StringComparison.Ordinal));
if (system is not null && selected.All((candidate) => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal))) if (system is not null && selected.All(candidate => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
{ {
selected.Add(system); selected.Add(system);
} }
@@ -86,7 +83,7 @@ public sealed partial class ScenarioLoader
break; break;
} }
if (selected.Any((candidate) => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal))) if (selected.Any(candidate => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
{ {
continue; continue;
} }
@@ -127,9 +124,8 @@ public sealed partial class ScenarioLoader
{ {
var starProfile = SelectStarProfile(generatedIndex); var starProfile = SelectStarProfile(generatedIndex);
var planets = BuildGeneratedPlanets(template, generatedIndex); var planets = BuildGeneratedPlanets(template, generatedIndex);
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex) var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
.Select((node) => new ResourceNodeDefinition .Select(node => new ResourceNodeDefinition
{ {
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
Angle = node.Angle, Angle = node.Angle,
@@ -185,40 +181,36 @@ public sealed partial class ScenarioLoader
RadiusVariance = definition.AsteroidField.RadiusVariance, RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance, HeightVariance = definition.AsteroidField.HeightVariance,
}, },
ResourceNodes = definition.ResourceNodes ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition
.Select((node) => new ResourceNodeDefinition {
{ SourceKind = node.SourceKind,
SourceKind = node.SourceKind, Angle = node.Angle,
Angle = node.Angle, RadiusOffset = node.RadiusOffset,
RadiusOffset = node.RadiusOffset, InclinationDegrees = node.InclinationDegrees,
InclinationDegrees = node.InclinationDegrees, AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorPlanetIndex = node.AnchorPlanetIndex, AnchorMoonIndex = node.AnchorMoonIndex,
AnchorMoonIndex = node.AnchorMoonIndex, OreAmount = node.OreAmount,
OreAmount = node.OreAmount, ItemId = node.ItemId,
ItemId = node.ItemId, ShardCount = node.ShardCount,
ShardCount = node.ShardCount, }).ToList(),
}) Planets = definition.Planets.Select(planet => new PlanetDefinition
.ToList(), {
Planets = definition.Planets Label = planet.Label,
.Select((planet) => new PlanetDefinition PlanetType = planet.PlanetType,
{ Shape = planet.Shape,
Label = planet.Label, Moons = planet.Moons.Select(moon => new MoonDefinition { Label = moon.Label, Size = moon.Size, Color = moon.Color, OrbitRadius = moon.OrbitRadius, OrbitSpeed = moon.OrbitSpeed, OrbitPhaseAtEpoch = moon.OrbitPhaseAtEpoch, OrbitInclination = moon.OrbitInclination, OrbitLongitudeOfAscendingNode = moon.OrbitLongitudeOfAscendingNode }).ToList(),
PlanetType = planet.PlanetType, OrbitRadius = planet.OrbitRadius,
Shape = planet.Shape, OrbitSpeed = planet.OrbitSpeed,
Moons = planet.Moons.Select(m => new MoonDefinition { Label = m.Label, Size = m.Size, Color = m.Color, OrbitRadius = m.OrbitRadius, OrbitSpeed = m.OrbitSpeed, OrbitPhaseAtEpoch = m.OrbitPhaseAtEpoch, OrbitInclination = m.OrbitInclination, OrbitLongitudeOfAscendingNode = m.OrbitLongitudeOfAscendingNode }).ToList(), OrbitEccentricity = planet.OrbitEccentricity,
OrbitRadius = planet.OrbitRadius, OrbitInclination = planet.OrbitInclination,
OrbitSpeed = planet.OrbitSpeed, OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitEccentricity = planet.OrbitEccentricity, OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitInclination = planet.OrbitInclination, OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, Size = planet.Size,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, Color = planet.Color,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, Tilt = planet.Tilt,
Size = planet.Size, HasRing = planet.HasRing,
Color = planet.Color, }).ToList(),
Tilt = planet.Tilt,
HasRing = planet.HasRing,
})
.ToList(),
}; };
} }
@@ -230,7 +222,7 @@ public sealed partial class ScenarioLoader
var nodes = new List<ResourceNodeDefinition>(); var nodes = new List<ResourceNodeDefinition>();
if (template.ResourceNodes.Count > 0) if (template.ResourceNodes.Count > 0)
{ {
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition
{ {
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
Angle = node.Angle, Angle = node.Angle,
@@ -259,7 +251,7 @@ public sealed partial class ScenarioLoader
for (var attempt = 0; attempt < 64; attempt += 1) for (var attempt = 0; attempt < 64; attempt += 1)
{ {
var candidate = ComputeGeneratedSystemPosition(index, attempt); var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{ {
accepted = candidate; accepted = candidate;
break; break;
@@ -307,7 +299,7 @@ public sealed partial class ScenarioLoader
{ {
var slug = string.Concat(label var slug = string.Concat(label
.ToLowerInvariant() .ToLowerInvariant()
.Select((character) => char.IsLetterOrDigit(character) ? character : '-')) .Select(character => char.IsLetterOrDigit(character) ? character : '-'))
.Trim('-'); .Trim('-');
return $"gen-{ordinal}-{slug}"; return $"gen-{ordinal}-{slug}";
@@ -359,9 +351,7 @@ public sealed partial class ScenarioLoader
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1); return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
} }
private static List<PlanetDefinition> BuildGeneratedPlanets( private static List<PlanetDefinition> BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex)
SolarSystemDefinition template,
int generatedIndex)
{ {
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount); var planets = new List<PlanetDefinition>(planetCount);
@@ -495,23 +485,4 @@ public sealed partial class ScenarioLoader
return moons; return moons;
} }
private sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize);
private sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + MathF.Max(0.12f, OrbitGapMin * 0.45f);
}
} }

View File

@@ -0,0 +1,182 @@
using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Api.Simulation;
internal sealed class WorldBuilder(
WorldGenerationOptions worldGeneration,
DataCatalogLoader dataLoader,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder,
WorldSeedingService seedingService)
{
internal SimulationWorld Build()
{
var catalog = dataLoader.LoadCatalog();
var systems = generationService.ExpandSystems(
generationService.InjectSpecialSystems(catalog.AuthoredSystems),
worldGeneration.TargetSystemCount);
var scenario = dataLoader.NormalizeScenarioToAvailableSystems(
catalog.Scenario,
systems.Select(system => system.Id).ToList());
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{
Definition = definition,
Position = ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance);
var stations = CreateStations(
scenario,
systemsById,
spatialLayout.SystemGraphs,
spatialLayout.Celestials,
catalog.ModuleDefinitions);
seedingService.InitializeStationStockpiles(stations);
var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, refinery);
var factions = seedingService.CreateFactions(stations, ships);
seedingService.BootstrapFactionEconomy(factions, stations);
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(stations, claims, catalog.ModuleRecipes);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = catalog.Balance,
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
Stations = stations,
Ships = ships,
Factions = factions,
Commanders = commanders,
Claims = claims,
ConstructionSites = constructionSites,
MarketOrders = 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),
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
private static List<StationRuntime> CreateStations(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions)
{
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
var placement = SpatialBuilder.ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
var station = new StationRuntime
{
Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id,
Label = plan.Label,
Color = plan.Color,
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
CelestialId = placement.AnchorCelestial.Id,
};
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
foreach (var moduleId in startingModules)
{
AddStationModule(station, moduleDefinitions, moduleId);
}
}
return stations;
}
private static Dictionary<string, List<Vector3>> BuildPatrolRoutes(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById)
{
return scenario.PatrolRoutes
.GroupBy(route => route.SystemId, StringComparer.Ordinal)
.ToDictionary(
group => group.Key,
group => group
.SelectMany(route => route.Points)
.Select(point => NormalizeScenarioPoint(systemsById[group.Key], point))
.ToList(),
StringComparer.Ordinal);
}
private static List<ShipRuntime> CreateShips(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials,
BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery)
{
var ships = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
continue;
}
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
ships.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? DefaultFactionId,
Position = position,
TargetPosition = position,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
DefaultBehavior = WorldSeedingService.CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth,
});
}
}
return ships;
}
}

View File

@@ -1,19 +1,21 @@
using SpaceGame.Simulation.Api.Data; using SpaceGame.Api.Data;
using SpaceGame.Api.Simulation.Model;
using static SpaceGame.Api.Simulation.LoaderSupport;
namespace SpaceGame.Simulation.Api.Simulation; namespace SpaceGame.Api.Simulation;
public sealed partial class ScenarioLoader internal sealed class WorldSeedingService
{ {
private static List<FactionRuntime> CreateFactions( internal List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships) IReadOnlyCollection<ShipRuntime> ships)
{ {
var factionIds = stations var factionIds = stations
.Select((station) => station.FactionId) .Select(station => station.FactionId)
.Concat(ships.Select((ship) => ship.FactionId)) .Concat(ships.Select(ship => ship.FactionId))
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId)) .Where(factionId => !string.IsNullOrWhiteSpace(factionId))
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.OrderBy((factionId) => factionId, StringComparer.Ordinal) .OrderBy(factionId => factionId, StringComparer.Ordinal)
.ToList(); .ToList();
if (factionIds.Count == 0) if (factionIds.Count == 0)
@@ -21,33 +23,10 @@ public sealed partial class ScenarioLoader
factionIds.Add(DefaultFactionId); factionIds.Add(DefaultFactionId);
} }
return factionIds return factionIds.Select(CreateFaction).ToList();
.Select(CreateFaction)
.ToList();
} }
private static FactionRuntime CreateFaction(string factionId) internal void BootstrapFactionEconomy(
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static void BootstrapFactionEconomy(
IReadOnlyCollection<FactionRuntime> factions, IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations) IReadOnlyCollection<StationRuntime> stations)
{ {
@@ -56,11 +35,11 @@ public sealed partial class ScenarioLoader
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits); faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
var ownedStations = stations var ownedStations = stations
.Where((station) => station.FactionId == faction.Id) .Where(station => station.FactionId == faction.Id)
.ToList(); .ToList();
var refineries = ownedStations var refineries = ownedStations
.Where((station) => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01")) .Where(station => HasInstalledModules(station, "module_gen_prod_refinedmetals_01", "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"))
.ToList(); .ToList();
if (refineries.Count > 0) if (refineries.Count > 0)
@@ -70,32 +49,52 @@ public sealed partial class ScenarioLoader
refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock); refinery.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refinedmetals"), MinimumRefineryStock);
} }
if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre)) if (refineries.All(station => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
{ {
refineries[0].Inventory["ore"] = MinimumRefineryOre; refineries[0].Inventory["ore"] = MinimumRefineryOre;
} }
} }
foreach (var shipyard in ownedStations.Where((station) => HasInstalledModules(station, "module_gen_build_l_01"))) foreach (var shipyard in ownedStations.Where(station => HasInstalledModules(station, "module_gen_build_l_01")))
{ {
shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock); shipyard.Inventory["refinedmetals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refinedmetals"), MinimumShipyardStock);
} }
} }
} }
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) => internal void InitializeStationStockpiles(IReadOnlyCollection<StationRuntime> stations)
inventory.TryGetValue(itemId, out var amount) ? amount : 0f; {
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refinedmetals"] = 120f;
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
}
}
}
private static List<ClaimRuntime> CreateClaims( internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
{
return stations.FirstOrDefault(station =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault(station =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
}
internal List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<CelestialRuntime> celestials, IReadOnlyCollection<CelestialRuntime> celestials,
DateTimeOffset nowUtc) DateTimeOffset nowUtc)
{ {
var stationsByCelestialId = stations var stationsByCelestialId = stations
.Where((station) => station.CelestialId is not null) .Where(station => station.CelestialId is not null)
.ToDictionary((station) => station.CelestialId!, StringComparer.Ordinal); .ToDictionary(station => station.CelestialId!, StringComparer.Ordinal);
var claims = new List<ClaimRuntime>(); var claims = new List<ClaimRuntime>();
foreach (var celestial in celestials.Where((c) => c.Kind == SpatialNodeKind.LagrangePoint))
foreach (var celestial in celestials.Where(c => c.Kind == SpatialNodeKind.LagrangePoint))
{ {
if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station)) if (!stationsByCelestialId.TryGetValue(celestial.Id, out var station))
{ {
@@ -118,7 +117,7 @@ public sealed partial class ScenarioLoader
return claims; return claims;
} }
private static (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites( internal (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ClaimRuntime> claims, IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes) IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
@@ -134,7 +133,7 @@ public sealed partial class ScenarioLoader
continue; continue;
} }
var claim = claims.FirstOrDefault((candidate) => candidate.CelestialId == station.CelestialId); var claim = claims.FirstOrDefault(candidate => candidate.CelestialId == station.CelestialId);
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe)) if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
{ {
continue; continue;
@@ -183,43 +182,7 @@ public sealed partial class ScenarioLoader
return (sites, orders); return (sites, orders);
} }
private static string? GetNextConstructionSiteModule( internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
})
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& moduleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.Population = habitatModules > 0
? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f)
: MathF.Min(28f, station.PopulationCapacity);
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private static List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{ {
var policies = new List<PolicySetRuntime>(factions.Count); var policies = new List<PolicySetRuntime>(factions.Count);
foreach (var faction in factions) foreach (var faction in factions)
@@ -237,14 +200,14 @@ public sealed partial class ScenarioLoader
return policies; return policies;
} }
private static List<CommanderRuntime> CreateCommanders( internal List<CommanderRuntime> CreateCommanders(
IReadOnlyCollection<FactionRuntime> factions, IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships) IReadOnlyCollection<ShipRuntime> ships)
{ {
var commanders = new List<CommanderRuntime>(); var commanders = new List<CommanderRuntime>();
var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal); var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal);
var factionsById = factions.ToDictionary((faction) => faction.Id, StringComparer.Ordinal); var factionsById = factions.ToDictionary(faction => faction.Id, StringComparer.Ordinal);
foreach (var faction in factions) foreach (var faction in factions)
{ {
@@ -330,15 +293,7 @@ public sealed partial class ScenarioLoader
return commanders; return commanders;
} }
private static string ToFactionLabel(string factionId) internal static DefaultBehaviorRuntime CreateBehavior(
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition, ShipDefinition definition,
string systemId, string systemId,
ScenarioDefinition scenario, ScenarioDefinition scenario,
@@ -376,6 +331,71 @@ public sealed partial class ScenarioLoader
}; };
} }
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static string? GetNextConstructionSiteModule(
StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
{
("module_gen_prod_refinedmetals_01", 1),
("module_arg_stor_container_m_01", 1),
("module_gen_prod_hullparts_01", 2),
("module_gen_prod_advancedelectronics_01", 1),
("module_gen_build_l_01", 1),
("module_gen_prod_energycells_01", 2),
("module_arg_dock_m_01_lowtech", 2),
})
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& moduleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "module_arg_hab_m_01");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.Modules.Count * 14f);
station.Population = habitatModules > 0
? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f)
: MathF.Min(28f, station.PopulationCapacity);
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(segment => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new() private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
{ {
Kind = kind, Kind = kind,

View File

@@ -1,608 +0,0 @@
using System.Text.Json;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader
{
private const string DefaultFactionId = "sol-dominion";
private const int WorldSeed = 1;
private const float MinimumFactionCredits = 0f;
private const float MinimumRefineryOre = 0f;
private const float MinimumRefineryStock = 0f;
private const float MinimumShipyardStock = 0f;
private const float MinimumSystemSeparation = 3.2f;
private const float LocalSpaceRadius = 10_000f;
private static readonly string[] GeneratedSystemNames =
[
"Aquila Verge",
"Orion Fold",
"Draco Span",
"Lyra Shoal",
"Cygnus March",
"Vela Crossing",
"Carina Wake",
"Phoenix Rest",
"Hydra Loom",
"Cassio Reach",
"Lupus Chain",
"Pavo Line",
"Serpens Rise",
"Cetus Hollow",
"Delphin Crown",
"Volans Drift",
"Ara Bastion",
"Indus Veil",
"Pyxis Trace",
"Lacerta Bloom",
"Columba Shroud",
"Dorado Expanse",
"Reticulum Run",
"Norma Edge",
"Crux Horizon",
"Sagitta Corridor",
"Monoceros Deep",
"Eridan Spur",
"Centauri Shelf",
"Antlia Reach",
"Horologium Gate",
"Telescopium Strand",
];
private static readonly StarProfile[] StarProfiles =
[
new("main-sequence", "#ffd27a", "#ffb14a", 696340f),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f),
];
private static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 2800f, 0.22f, 0, false),
new("terrestrial", "sphere", "#58a36c", 6400f, 0.28f, 1, false),
new("oceanic", "sphere", "#4f84c4", 7000f, 0.30f, 2, false),
new("desert", "sphere", "#d4a373", 5200f, 0.26f, 0, false),
new("ice", "sphere", "#c8e4ff", 5800f, 0.32f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 45000f, 1.40f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 25000f, 1.00f, 5, true),
new("lava", "sphere", "#db6846", 3200f, 0.20f, 0, false),
];
private readonly string _dataRoot;
private readonly WorldGenerationOptions _worldGeneration;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
_worldGeneration = worldGeneration ?? new WorldGenerationOptions();
}
public SimulationWorld Load()
{
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var systems = ExpandSystems(
InjectSpecialSystems(authoredSystems),
_worldGeneration.TargetSystemCount);
var scenario = NormalizeScenarioToAvailableSystems(
Read<ScenarioDefinition>("scenario.json"),
systems.Select((system) => system.Id).ToList());
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 moduleDefinitions = modules.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var recipeDefinitions = recipes.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal);
var systemRuntimes = systems
.Select((definition) => new SystemRuntime
{
Definition = definition,
Position = ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary((system) => system.Definition.Id, StringComparer.Ordinal);
var systemGraphs = systemRuntimes.ToDictionary(
(system) => system.Definition.Id,
(system) => BuildSystemSpatialGraph(system),
StringComparer.Ordinal);
var celestials = new List<CelestialRuntime>();
var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var graph in systemGraphs.Values)
{
celestials.AddRange(graph.Celestials);
}
foreach (var system in systemRuntimes)
{
var systemGraph = systemGraphs[system.Definition.Id];
foreach (var node in system.Definition.ResourceNodes)
{
var anchorCelestial = ResolveResourceNodeAnchor(systemGraph, node);
var resourceNode = new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = ComputeResourceNodePosition(anchorCelestial, node, balance.YPlane),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
CelestialId = anchorCelestial?.Id,
OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
};
nodes.Add(resourceNode);
}
}
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
var placement = ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], celestials);
var station = new StationRuntime
{
Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id,
Label = plan.Label,
Color = plan.Color,
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
};
station.CelestialId = placement.AnchorCelestial.Id;
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_solid_m_01", "module_arg_stor_liquid_m_01"];
foreach (var moduleId in startingModules)
{
AddStationModule(stations[^1], moduleDefinitions, moduleId);
}
}
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refinedmetals"] = 120f;
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
}
}
var refinery = stations.FirstOrDefault((station) =>
HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "module_gen_prod_energycells_01", "module_arg_stor_liquid_m_01"));
var patrolRoutes = scenario.PatrolRoutes
.GroupBy((route) => route.SystemId, StringComparer.Ordinal)
.ToDictionary(
(group) => group.Key,
(group) => group
.SelectMany((route) => route.Points)
.Select((point) => NormalizeScenarioPoint(systemsById[group.Key], point))
.ToList(),
StringComparer.Ordinal);
var shipsRuntime = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
continue;
}
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
shipsRuntime.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? DefaultFactionId,
Position = position,
TargetPosition = position,
SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, celestials),
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth,
});
}
}
var factions = CreateFactions(stations, shipsRuntime);
BootstrapFactionEconomy(factions, stations);
var policies = CreatePolicies(factions);
var commanders = CreateCommanders(factions, stations, shipsRuntime);
var now = DateTimeOffset.UtcNow;
var claims = CreateClaims(stations, celestials, now);
var (constructionSites, marketOrders) = CreateConstructionSites(stations, claims, moduleRecipeDefinitions);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = balance,
Systems = systemRuntimes,
Celestials = celestials,
Nodes = nodes,
Stations = stations,
Ships = shipsRuntime,
Factions = factions,
Commanders = commanders,
Claims = claims,
ConstructionSites = constructionSites,
MarketOrders = marketOrders,
Policies = policies,
ShipDefinitions = shipDefinitions,
ItemDefinitions = itemDefinitions,
ModuleDefinitions = moduleDefinitions,
ModuleRecipes = moduleRecipeDefinitions,
Recipes = recipeDefinitions,
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
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 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,
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,
})
.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 Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
{
var raw = ToVector(values);
var relativeToSystem = new Vector3(
raw.X - system.Position.X,
raw.Y - system.Position.Y,
raw.Z - system.Position.Z);
return relativeToSystem.LengthSquared() < raw.LengthSquared()
? relativeToSystem
: raw;
}
private static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All((moduleId) => station.Modules.Any((candidate) => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
private static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All((cap) => definition.Capabilities.Contains(cap, StringComparer.Ordinal));
private static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
}
station.Modules.Add(new StationModuleRuntime
{
Id = $"{station.Id}-module-{station.Modules.Count + 1}",
ModuleId = moduleId,
Health = definition.Hull,
MaxHealth = definition.Hull,
});
station.Radius = GetStationRadius(moduleDefinitions, station);
}
private static float GetStationRadius(IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, StationRuntime station)
{
var totalArea = station.Modules
.Select((module) => moduleDefinitions.TryGetValue(module.ModuleId, out var definition) ? definition.Radius * definition.Radius : 0f)
.Sum();
return MathF.Max(24f, MathF.Sqrt(MathF.Max(totalArea, 1f)));
}
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal));
private static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
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;
}
private static Vector3 Scale(Vector3 vector, float scale) => new(vector.X * scale, vector.Y * scale, vector.Z * scale);
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
private static Vector3 NormalizeOrFallback(Vector3 vector, Vector3 fallback)
{
var length = MathF.Sqrt(vector.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return vector.Divide(length);
}
}