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

1497 lines
52 KiB
C#

using System.Text.Json;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class ScenarioLoader
{
private const string DefaultFactionId = "sol-dominion";
private const int TargetSystemCount = 160;
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 = 3200f;
private const float StarBubbleRadiusPadding = 40f;
private const float PlanetBubbleRadiusPadding = 80f;
private const float MoonBubbleRadiusPadding = 40f;
private const float LagrangeBubbleRadius = 150f;
private const float ResourceBubbleRadius = 120f;
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", 54f, 1),
new("blue-white", "#9dc6ff", "#66a0ff", 50f, 1),
new("white-dwarf", "#f1f5ff", "#b8caff", 26f, 1),
new("brown-dwarf", "#b97d56", "#8a5438", 20f, 1),
new("neutron-star", "#d9ebff", "#7ab4ff", 18f, 1),
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 64f, 2),
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 34f, 2),
];
private static readonly PlanetProfile[] PlanetProfiles =
[
new("barren", "sphere", "#bca48f", 18f, 38f, 0, false),
new("terrestrial", "sphere", "#58a36c", 24f, 46f, 1, false),
new("oceanic", "sphere", "#4f84c4", 26f, 44f, 2, false),
new("desert", "sphere", "#d4a373", 22f, 42f, 0, false),
new("ice", "sphere", "#c8e4ff", 24f, 40f, 1, false),
new("gas-giant", "oblate", "#d9b06f", 52f, 86f, 8, true),
new("ice-giant", "oblate", "#8fc0d8", 44f, 72f, 5, true),
new("lava", "sphere", "#db6846", 20f, 36f, 0, false),
];
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public ScenarioLoader(string contentRootPath)
{
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
}
public SimulationWorld Load()
{
var systems = ExpandSystems(InjectSpecialSystems(Read<List<SolarSystemDefinition>>("systems.json")));
var scenario = Read<ScenarioDefinition>("scenario.json");
var ships = Read<List<ShipDefinition>>("ships.json");
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
var items = Read<List<ItemDefinition>>("items.json");
var recipes = Read<List<RecipeDefinition>>("recipes.json");
var moduleRecipes = Read<List<ModuleRecipeDefinition>>("module-recipes.json");
var balance = Read<BalanceDefinition>("balance.json");
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var constructibleDefinitions = constructibles.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 nodes = new List<ResourceNodeRuntime>();
var spatialNodes = new List<NodeRuntime>();
var localBubbles = new List<LocalBubbleRuntime>();
var nodeIdCounter = 0;
foreach (var graph in systemGraphs.Values)
{
spatialNodes.AddRange(graph.Nodes);
localBubbles.AddRange(graph.Bubbles);
}
foreach (var system in systemRuntimes)
{
foreach (var node in system.Definition.ResourceNodes)
{
var resourceNode = new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = new Vector3(
MathF.Cos(node.Angle) * node.RadiusOffset,
balance.YPlane,
MathF.Sin(node.Angle) * node.RadiusOffset),
SourceKind = node.SourceKind,
ItemId = node.ItemId,
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
};
nodes.Add(resourceNode);
var bubbleId = $"bubble-{resourceNode.Id}";
spatialNodes.Add(new NodeRuntime
{
Id = resourceNode.Id,
SystemId = resourceNode.SystemId,
Kind = "resource-site",
Position = resourceNode.Position,
BubbleId = bubbleId,
});
localBubbles.Add(new LocalBubbleRuntime
{
Id = bubbleId,
NodeId = resourceNode.Id,
SystemId = resourceNode.SystemId,
Radius = ResourceBubbleRadius,
});
}
}
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!constructibleDefinitions.TryGetValue(plan.ConstructibleId, out var definition) || !systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
var placement = ResolveStationPlacement(plan, system, systemGraphs[system.Definition.Id], spatialNodes);
var station = new StationRuntime
{
Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id,
Definition = definition,
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
};
var stationNodeId = $"node-{station.Id}";
var stationBubbleId = $"bubble-{station.Id}";
station.NodeId = stationNodeId;
station.BubbleId = stationBubbleId;
station.AnchorNodeId = placement.AnchorNode.Id;
stations.Add(station);
spatialNodes.Add(new NodeRuntime
{
Id = stationNodeId,
SystemId = station.SystemId,
Kind = "station",
Position = station.Position,
BubbleId = stationBubbleId,
ParentNodeId = placement.AnchorNode.Id,
OccupyingStructureId = station.Id,
});
localBubbles.Add(new LocalBubbleRuntime
{
Id = stationBubbleId,
NodeId = stationNodeId,
SystemId = station.SystemId,
Radius = MathF.Max(160f, definition.Radius + 60f),
});
localBubbles[^1].OccupantStationIds.Add(station.Id);
placement.AnchorNode.OccupyingStructureId = station.Id;
foreach (var moduleId in definition.Modules)
{
stations[^1].InstalledModules.Add(moduleId);
}
}
foreach (var station in stations)
{
InitializeStationPopulation(station);
station.Inventory["fuel"] = 240f;
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.ToDictionary(
(route) => route.SystemId,
(route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], 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, spatialNodes),
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = "pending" },
Health = definition.MaxHealth,
});
shipsRuntime[^1].Inventory["gas"] = definition.Id switch
{
_ => 0f,
};
shipsRuntime[^1].Inventory.Remove("gas");
shipsRuntime[^1].Inventory["fuel"] = definition.Id switch
{
"constructor" => 90f,
"miner" => 90f,
"gas-miner" => 90f,
_ => 120f,
};
}
}
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, spatialNodes, now);
var (constructionSites, marketOrders) = CreateConstructionSites(stations, claims, spatialNodes, moduleRecipeDefinitions);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = balance,
Systems = systemRuntimes,
Nodes = nodes,
SpatialNodes = spatialNodes,
LocalBubbles = localBubbles,
Stations = stations,
Ships = shipsRuntime,
Factions = factions,
Commanders = commanders,
Claims = claims,
ConstructionSites = constructionSites,
MarketOrders = marketOrders,
Policies = policies,
ShipDefinitions = shipDefinitions,
ItemDefinitions = itemDefinitions,
ModuleRecipes = moduleRecipeDefinitions,
Recipes = recipeDefinitions,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (systems.All((system) => system.Id != "sol"))
{
systems.Add(CreateSolSystem());
}
return systems;
}
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0)
{
return systems;
}
var existingIds = systems
.Select((system) => system.Id)
.ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count);
for (var index = systems.Count; index < TargetSystemCount; index += 1)
{
var template = authoredSystems[index % authoredSystems.Count];
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, index + 1);
while (!existingIds.Add(id))
{
id = $"{id}-x";
}
systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count]));
}
return systems;
}
private static SolarSystemDefinition CreateGeneratedSystem(
SolarSystemDefinition template,
string label,
string id,
int generatedIndex,
Vector3 position)
{
var starProfile = SelectStarProfile(generatedIndex);
var planets = BuildGeneratedPlanets(template, generatedIndex);
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList();
return new SolarSystemDefinition
{
Id = id,
Label = label,
Position = [position.X, position.Y, position.Z],
StarKind = starProfile.Kind,
StarCount = starProfile.StarCount,
StarColor = starProfile.StarColor,
StarGlow = starProfile.StarGlow,
StarSize = starProfile.BaseSize + ((generatedIndex % 4) * 2f),
GravityWellRadius = template.GravityWellRadius + ((generatedIndex % 3) * 12f),
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10),
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18f),
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12f),
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4f),
},
ResourceNodes = resourceNodes,
Planets = planets,
};
}
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
{
return new SolarSystemDefinition
{
Id = definition.Id,
Label = definition.Label,
Position = definition.Position.ToArray(),
StarKind = definition.StarKind,
StarCount = definition.StarCount,
StarColor = definition.StarColor,
StarGlow = definition.StarGlow,
StarSize = definition.StarSize,
GravityWellRadius = definition.GravityWellRadius,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = definition.AsteroidField.DecorationCount,
RadiusOffset = definition.AsteroidField.RadiusOffset,
RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance,
},
ResourceNodes = definition.ResourceNodes
.Select((node) => new ResourceNodeDefinition
{
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList(),
Planets = definition.Planets
.Select((planet) => new PlanetDefinition
{
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
MoonCount = planet.MoonCount,
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
OrbitInclination = planet.OrbitInclination,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
Size = planet.Size,
Color = planet.Color,
Tilt = planet.Tilt,
HasRing = planet.HasRing,
})
.ToList(),
};
}
private static List<ResourceNodeDefinition> BuildProceduralResourceNodes(
SolarSystemDefinition template,
IReadOnlyList<PlanetDefinition> planets,
int generatedIndex)
{
var nodes = new List<ResourceNodeDefinition>();
if (template.ResourceNodes.Count > 0)
{
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}));
}
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
nodes.AddRange(BuildGasCloudNodes(generatedIndex, planets));
return nodes;
}
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
{
var allPositions = occupiedPositions.ToList();
var generated = new List<Vector3>(count);
for (var index = 0; index < count; index += 1)
{
Vector3? accepted = null;
for (var attempt = 0; attempt < 64; attempt += 1)
{
var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
accepted = candidate;
break;
}
}
accepted ??= ComputeFallbackGeneratedSystemPosition(index);
generated.Add(accepted.Value);
allPositions.Add(accepted.Value);
}
return generated;
}
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
{
const int armCount = 4;
const float baseInnerRadius = 9000f;
const float radiusStep = 540f;
const float armOffset = MathF.PI * 2f / armCount;
var armIndex = (generatedIndex + attempt) % armCount;
var armDepth = generatedIndex / armCount;
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 900f);
var angle = (armIndex * armOffset) + (radius / 8200f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
var x = MathF.Cos(angle) * radius;
var z = MathF.Sin(angle) * radius * 0.58f;
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
return new Vector3(x, y, z);
}
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
{
const int ringCount = 5;
const float fallbackRadius = 42000f;
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f;
return new Vector3(
MathF.Cos(angle) * radius,
ComputeSystemHeight(radius, generatedIndex, 99),
MathF.Sin(angle) * radius * 0.6f);
}
private static string BuildGeneratedSystemId(string label, int ordinal)
{
var slug = string.Concat(label
.ToLowerInvariant()
.Select((character) => char.IsLetterOrDigit(character) ? character : '-'))
.Trim('-');
return $"gen-{ordinal}-{slug}";
}
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 2800f + ((generatedIndex % 5) * 320f);
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f),
OreAmount = oreAmount,
ItemId = "ore",
ShardCount = 6 + (index % 4),
};
}
}
private static IEnumerable<ResourceNodeDefinition> BuildGasCloudNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var gasAnchor = planets
.Where((planet) => planet.PlanetType is "gas-giant" or "ice-giant")
.OrderByDescending((planet) => planet.OrbitRadius)
.FirstOrDefault();
if (gasAnchor is null)
{
yield break;
}
var nodeCount = 2 + (generatedIndex % 3);
var gasAmount = 2200f + ((generatedIndex % 4) * 260f);
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "gas-cloud",
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f),
OreAmount = gasAmount,
ItemId = "gas",
ShardCount = 10 + index,
};
}
}
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex)
{
var gap = planets
.Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius))
.OrderByDescending((entry) => entry.Gap)
.FirstOrDefault();
if (gap.Gap > 1f)
{
return gap.LeftOrbitRadius + (gap.Gap * 0.52f);
}
return 420f + ((generatedIndex % 5) * 60f);
}
private static List<PlanetDefinition> BuildGeneratedPlanets(
SolarSystemDefinition template,
int generatedIndex)
{
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount);
var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f);
var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null;
for (var index = 0; index < planetCount; index += 1)
{
var profile = SelectPlanetProfile(generatedIndex, index);
var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0
? sourcePlanets[index % sourcePlanets.Count]
: null;
orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin));
var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f);
var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f);
var moonVariance = (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
planets.Add(new PlanetDefinition
{
Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}",
PlanetType = profile.Type,
Shape = profile.Shape,
MoonCount = profile.BaseMoonCount + moonVariance,
OrbitRadius = orbitRadius,
OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f,
OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f,
OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f,
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * 10f),
Color = templatePlanet?.Color ?? profile.Color,
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
});
}
return planets;
}
private static StarProfile SelectStarProfile(int generatedIndex)
{
var value = Hash01(generatedIndex, 80);
return value switch
{
< 0.32f => StarProfiles[0],
< 0.54f => StarProfiles[1],
< 0.68f => StarProfiles[5],
< 0.8f => StarProfiles[2],
< 0.9f => StarProfiles[3],
< 0.97f => StarProfiles[6],
_ => StarProfiles[4],
};
}
private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex)
{
var value = Hash01(generatedIndex, 90 + planetIndex);
return value switch
{
< 0.14f => PlanetProfiles[7],
< 0.28f => PlanetProfiles[0],
< 0.46f => PlanetProfiles[3],
< 0.62f => PlanetProfiles[1],
< 0.74f => PlanetProfiles[2],
< 0.86f => PlanetProfiles[4],
< 0.94f => PlanetProfiles[6],
_ => PlanetProfiles[5],
};
}
private static string BuildPlanetBaseName(int generatedIndex, int planetIndex)
{
var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length]
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0];
return source[..Math.Min(source.Length, 6)];
}
private static float ComputeSystemHeight(float radius, int generatedIndex, int salt)
{
var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8000f) / 28000f));
var band = 220f + (normalized * 760f);
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
}
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
private static float Hash01(int index, int salt)
{
uint value = (uint)(index + 1);
value ^= (uint)(salt + 0x9e3779b9);
value *= 0x85ebca6b;
value ^= value >> 13;
value *= 0xc2b2ae35;
value ^= value >> 16;
return (value & 0x00ffffff) / 16777215f;
}
private sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize,
int StarCount);
private sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + 44f;
}
private static SolarSystemDefinition CreateSolSystem()
{
return new SolarSystemDefinition
{
Id = "sol",
Label = "Sol",
Position = [18200f, 24f, -11800f],
StarKind = "main-sequence",
StarCount = 1,
StarColor = "#fff1b8",
StarGlow = "#ffd35a",
StarSize = 58f,
GravityWellRadius = 240f,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = 240,
RadiusOffset = 780f,
RadiusVariance = 180f,
HeightVariance = 22f,
},
ResourceNodes =
[
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 },
],
Planets =
[
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
],
};
}
private static PlanetDefinition CreateSolPlanet(
string label,
string planetType,
string shape,
int moonCount,
float orbitRadius,
float orbitSpeed,
float orbitEccentricity,
float orbitInclination,
float ascendingNode,
float argumentOfPeriapsis,
float phaseAtEpoch,
string color,
float tilt,
bool hasRing)
{
return new PlanetDefinition
{
Label = label,
PlanetType = planetType,
Shape = shape,
MoonCount = moonCount,
OrbitRadius = orbitRadius,
OrbitSpeed = orbitSpeed,
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
OrbitArgumentOfPeriapsis = argumentOfPeriapsis,
OrbitPhaseAtEpoch = phaseAtEpoch,
Size = planetType switch
{
"gas-giant" => label == "Saturn" ? 66f : 72f,
"ice-giant" => 48f,
_ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f,
},
Color = color,
Tilt = tilt,
HasRing = hasRing,
};
}
private static List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var factionIds = stations
.Select((station) => station.FactionId)
.Concat(ships.Select((ship) => ship.FactionId))
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId))
.Distinct(StringComparer.Ordinal)
.OrderBy((factionId) => factionId, StringComparer.Ordinal)
.ToList();
if (factionIds.Count == 0)
{
factionIds.Add(DefaultFactionId);
}
return factionIds.Select(CreateFaction).ToList();
}
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 void BootstrapFactionEconomy(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations)
{
foreach (var faction in factions)
{
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
var ownedStations = stations
.Where((station) => station.FactionId == faction.Id)
.ToList();
var refineries = ownedStations
.Where((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank"))
.ToList();
if (refineries.Count > 0)
{
foreach (var refinery in refineries)
{
refinery.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refined-metals"), MinimumRefineryStock);
}
if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
{
refineries[0].Inventory["ore"] = MinimumRefineryOre;
}
}
foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard"))
{
shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock);
}
}
}
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
{
var nodes = new List<NodeRuntime>();
var bubbles = new List<LocalBubbleRuntime>();
var lagrangeNodesByPlanetIndex = new Dictionary<int, Dictionary<string, NodeRuntime>>();
var starNode = AddSpatialNode(
nodes,
bubbles,
id: $"node-{system.Definition.Id}-star",
systemId: system.Definition.Id,
kind: "star",
position: Vector3.Zero,
radius: MathF.Max(system.Definition.GravityWellRadius + StarBubbleRadiusPadding, 180f));
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
var planet = system.Definition.Planets[planetIndex];
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
var planetPosition = ComputePlanetPosition(planet);
var planetNode = AddSpatialNode(
nodes,
bubbles,
id: planetNodeId,
systemId: system.Definition.Id,
kind: "planet",
position: planetPosition,
radius: MathF.Max(planet.Size + PlanetBubbleRadiusPadding, 120f),
parentNodeId: starNode.Id);
var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
{
var lagrangeNode = AddSpatialNode(
nodes,
bubbles,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}",
systemId: system.Definition.Id,
kind: "lagrange-point",
position: point.Position,
radius: LagrangeBubbleRadius,
parentNodeId: planetNode.Id,
orbitReferenceId: point.Designation);
lagrangeNodes[point.Designation] = lagrangeNode;
}
lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes;
if (planet.MoonCount <= 0)
{
continue;
}
var moonOrbitRadius = MathF.Max(planet.Size + 48f, 42f);
for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1)
{
var moonPosition = ComputeMoonPosition(planetPosition, moonOrbitRadius, moonIndex, planetIndex);
AddSpatialNode(
nodes,
bubbles,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}",
systemId: system.Definition.Id,
kind: "moon",
position: moonPosition,
radius: MoonBubbleRadiusPadding + 24f,
parentNodeId: planetNode.Id);
moonOrbitRadius += 30f;
}
}
return new SystemSpatialGraph(system.Definition.Id, nodes, bubbles, lagrangeNodesByPlanetIndex);
}
private static NodeRuntime AddSpatialNode(
ICollection<NodeRuntime> nodes,
ICollection<LocalBubbleRuntime> bubbles,
string id,
string systemId,
string kind,
Vector3 position,
float radius,
string? parentNodeId = null,
string? orbitReferenceId = null)
{
var bubbleId = $"bubble-{id}";
var node = new NodeRuntime
{
Id = id,
SystemId = systemId,
Kind = kind,
Position = position,
BubbleId = bubbleId,
ParentNodeId = parentNodeId,
OrbitReferenceId = orbitReferenceId,
};
nodes.Add(node);
bubbles.Add(new LocalBubbleRuntime
{
Id = bubbleId,
NodeId = id,
SystemId = systemId,
Radius = radius,
});
return node;
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f));
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadius));
yield return new LagrangePointPlacement(
"L4",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
}
private static StationPlacement ResolveStationPlacement(
InitialStationDefinition plan,
SystemRuntime system,
SystemSpatialGraph graph,
IReadOnlyCollection<NodeRuntime> existingNodes)
{
if (plan.PlanetIndex is int planetIndex &&
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
{
var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
if (lagrangeNodes.TryGetValue(designation, out var lagrangeNode))
{
return new StationPlacement(lagrangeNode, lagrangeNode.Position);
}
}
if (plan.Position is { Length: 3 })
{
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredNode = existingNodes
.Where((node) => node.SystemId == system.Definition.Id && node.Kind == "lagrange-point")
.OrderBy((node) => node.Position.DistanceTo(targetPosition))
.FirstOrDefault()
?? existingNodes
.Where((node) => node.SystemId == system.Definition.Id)
.OrderBy((node) => node.Position.DistanceTo(targetPosition))
.First();
return new StationPlacement(preferredNode, preferredNode.Position);
}
var fallbackNode = graph.Nodes
.FirstOrDefault((node) => node.Kind == "lagrange-point" && string.IsNullOrEmpty(node.OccupyingStructureId))
?? graph.Nodes.First((node) => node.Kind == "planet");
return new StationPlacement(fallbackNode, fallbackNode.Position);
}
private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch
{
< 0 => "L4",
> 0 => "L5",
_ => "L1",
};
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
{
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
var x = MathF.Cos(angle) * planet.OrbitRadius;
var z = MathF.Sin(angle) * planet.OrbitRadius;
return new Vector3(x, 0f, z);
}
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, float orbitRadius, int moonIndex, int planetIndex)
{
var angle = ((MathF.PI * 2f) / MathF.Max(1, moonIndex + 3)) * (moonIndex + 1) + (planetIndex * 0.37f);
return Add(planetPosition, new Vector3(MathF.Cos(angle) * orbitRadius, 0f, MathF.Sin(angle) * orbitRadius));
}
private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<NodeRuntime> nodes)
{
var nearestNode = nodes
.Where((node) => node.SystemId == systemId)
.OrderBy((node) => node.Position.DistanceTo(position))
.FirstOrDefault();
return new ShipSpatialStateRuntime
{
CurrentSystemId = systemId,
SpaceLayer = SpaceLayerKinds.LocalSpace,
CurrentNodeId = nearestNode?.Id,
CurrentBubbleId = nearestNode?.BubbleId,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKinds.LocalFlight,
};
}
private static List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<NodeRuntime> nodes,
DateTimeOffset nowUtc)
{
var claims = new List<ClaimRuntime>(stations.Count);
foreach (var station in stations)
{
if (station.AnchorNodeId is null)
{
continue;
}
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
if (anchorNode is null)
{
continue;
}
claims.Add(new ClaimRuntime
{
Id = $"claim-{station.Id}",
FactionId = station.FactionId,
SystemId = station.SystemId,
NodeId = anchorNode.Id,
BubbleId = anchorNode.BubbleId,
PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating,
Health = 100f,
});
}
return claims;
}
private static (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyCollection<NodeRuntime> nodes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
var sites = new List<ConstructionSiteRuntime>();
var orders = new List<MarketOrderRuntime>();
foreach (var station in stations)
{
var moduleId = GetNextConstructionSiteModule(station, moduleRecipes);
if (moduleId is null || station.AnchorNodeId is null)
{
continue;
}
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
var claim = claims.FirstOrDefault((candidate) => candidate.Id == $"claim-{station.Id}");
if (anchorNode is null || claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
{
continue;
}
var site = new ConstructionSiteRuntime
{
Id = $"site-{station.Id}",
FactionId = station.FactionId,
SystemId = station.SystemId,
NodeId = anchorNode.Id,
BubbleId = anchorNode.BubbleId,
TargetKind = "station-module",
TargetDefinitionId = station.Definition.Id,
BlueprintId = moduleId,
ClaimId = claim.Id,
StationId = station.Id,
State = claim.State == ClaimStateKinds.Active ? ConstructionSiteStateKinds.Active : ConstructionSiteStateKinds.Planned,
};
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{moduleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
orders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
sites.Add(site);
}
return (sites, orders);
}
private static string? GetNextConstructionSiteModule(
StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
{
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
&& moduleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "habitat-ring");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.InstalledModules.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);
foreach (var faction in factions)
{
var policyId = $"policy-{faction.Id}";
faction.DefaultPolicySetId = policyId;
policies.Add(new PolicySetRuntime
{
Id = policyId,
OwnerKind = "faction",
OwnerId = faction.Id,
});
}
return policies;
}
private static List<CommanderRuntime> CreateCommanders(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var commanders = new List<CommanderRuntime>();
var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal);
var factionsById = factions.ToDictionary((faction) => faction.Id, StringComparer.Ordinal);
foreach (var faction in factions)
{
var commander = new CommanderRuntime
{
Id = $"commander-faction-{faction.Id}",
Kind = CommanderKind.Faction,
FactionId = faction.Id,
ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-default",
};
commanders.Add(commander);
factionCommanders[faction.Id] = commander;
faction.CommanderIds.Add(commander.Id);
}
foreach (var station in stations)
{
if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-station-{station.Id}",
Kind = CommanderKind.Station,
FactionId = station.FactionId,
ParentCommanderId = parentCommander.Id,
ControlledEntityId = station.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "station-default",
};
station.CommanderId = commander.Id;
station.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
factionsById[station.FactionId].CommanderIds.Add(commander.Id);
commanders.Add(commander);
}
foreach (var ship in ships)
{
if (!factionCommanders.TryGetValue(ship.FactionId, out var parentCommander))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-ship-{ship.Id}",
Kind = CommanderKind.Ship,
FactionId = ship.FactionId,
ParentCommanderId = parentCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "ship-default",
ActiveBehavior = CopyBehavior(ship.DefaultBehavior),
ActiveTask = CopyTask(ship.ControllerTask, null),
};
if (ship.Order is not null)
{
commander.ActiveOrder = CopyOrder(ship.Order);
}
ship.CommanderId = commander.Id;
ship.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
factionsById[ship.FactionId].CommanderIds.Add(commander.Id);
commanders.Add(commander);
}
return commanders;
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
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 DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery)
{
if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
StationId = refinery.Id,
Phase = "travel-to-station",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-harvest-gas",
StationId = refinery.Id,
Phase = "travel-to-node",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
StationId = refinery.Id,
Phase = "travel-to-node",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints = route,
PatrolIndex = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = "idle",
};
}
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 HasModules(ConstructibleDefinition definition, params string[] modules) =>
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
private static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
private static bool HasModules(ShipDefinition definition, params string[] modules) =>
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
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 CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
{
Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId,
ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId,
Phase = behavior.Phase,
PatrolIndex = behavior.PatrolIndex,
StationId = behavior.StationId,
};
private static CommanderOrderRuntime CopyOrder(ShipOrderRuntime order) => new()
{
Kind = order.Kind,
Status = order.Status,
DestinationSystemId = order.DestinationSystemId,
DestinationPosition = order.DestinationPosition,
};
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
{
Kind = task.Kind,
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = targetNodeId ?? task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
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);
}
private sealed record SystemSpatialGraph(
string SystemId,
List<NodeRuntime> Nodes,
List<LocalBubbleRuntime> Bubbles,
Dictionary<int, Dictionary<string, NodeRuntime>> LagrangeNodesByPlanetIndex);
private sealed record LagrangePointPlacement(string Designation, Vector3 Position);
private sealed record StationPlacement(NodeRuntime AnchorNode, Vector3 Position);
}