Files
space-game/apps/backend/Simulation/ScenarioLoader.cs

500 lines
17 KiB
C#

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 StarBubbleRadiusPadding = 40f;
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, 1),
new("blue-white", "#9dc6ff", "#66a0ff", 930000f, 1),
new("white-dwarf", "#f1f5ff", "#b8caff", 12000f, 1),
new("brown-dwarf", "#b97d56", "#8a5438", 70000f, 1),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f, 1),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 780000f, 2),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 14000f, 2),
];
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.IncludeSolSystem),
_worldGeneration.TargetSystemCount);
var scenario = NormalizeScenarioToAvailableSystems(
Read<ScenarioDefinition>("scenario.json"),
systems.Select((system) => system.Id).ToList());
var modules = 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);
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
: ["dock-bay-small", "power-core", "bulk-bay", "liquid-tank"];
foreach (var moduleId in startingModules)
{
AddStationModule(stations[^1], moduleDefinitions, moduleId);
}
}
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["refined-metals"] = 120f;
if (station.Population > 0f)
{
station.Inventory["water"] = MathF.Max(80f, station.Population * 1.5f);
}
}
var refinery = stations.FirstOrDefault((station) =>
HasInstalledModules(station, "power-core", "liquid-tank") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank"));
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)
.Select((module) => new ModuleRecipeDefinition
{
ModuleId = module.Id,
Duration = module.Construction!.ProductionTime,
Inputs = module.Construction.Requirements
.Select((input) => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
})
.ToList();
private static List<RecipeDefinition> BuildRecipes(IEnumerable<ItemDefinition> items, IEnumerable<ShipDefinition> ships)
{
var recipes = new List<RecipeDefinition>();
foreach (var item in items)
{
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 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);
}
}