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

929 lines
32 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 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 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 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 nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var system in systemRuntimes)
{
foreach (var node in system.Definition.ResourceNodes)
{
nodes.Add(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,
});
}
}
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;
}
stations.Add(new StationRuntime
{
Id = $"station-{++stationIdCounter}",
SystemId = system.Definition.Id,
Definition = definition,
Position = ResolveStationPosition(system, plan, balance),
FactionId = plan.FactionId ?? DefaultFactionId,
});
foreach (var moduleId in definition.Modules)
{
stations[^1].InstalledModules.Add(moduleId);
}
}
foreach (var station in stations)
{
station.Inventory["fuel"] = 240f;
station.Inventory["refined-metals"] = 120f;
}
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,
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold },
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);
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = WorldSeed,
Balance = balance,
Systems = systemRuntimes,
Nodes = nodes,
Stations = stations,
Ships = shipsRuntime,
Factions = factions,
ShipDefinitions = shipDefinitions,
ItemDefinitions = itemDefinitions,
ModuleRecipes = moduleRecipeDefinitions,
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 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 ResolveStationPosition(SystemRuntime system, InitialStationDefinition plan, BalanceDefinition balance)
{
if (plan.Position is { Length: 3 })
{
return NormalizeScenarioPoint(system, plan.Position);
}
if (plan.PlanetIndex is int planetIndex && planetIndex >= 0 && planetIndex < system.Definition.Planets.Count)
{
var planet = system.Definition.Planets[planetIndex];
var side = plan.LagrangeSide ?? 1;
return new Vector3(
planet.OrbitRadius + (side * 72f),
balance.YPlane,
(planetIndex + 1) * 42f * side);
}
return new Vector3(180f, balance.YPlane, 0f);
}
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);
}