refactor(backend): modularize scenario loader
This commit is contained in:
302
apps/backend/Simulation/Scenario/DataCatalogLoader.cs
Normal file
302
apps/backend/Simulation/Scenario/DataCatalogLoader.cs
Normal 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);
|
||||||
173
apps/backend/Simulation/Scenario/LoaderSupport.cs
Normal file
173
apps/backend/Simulation/Scenario/LoaderSupport.cs
Normal 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);
|
||||||
|
}
|
||||||
27
apps/backend/Simulation/Scenario/ScenarioLoader.cs
Normal file
27
apps/backend/Simulation/Scenario/ScenarioLoader.cs
Normal 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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
182
apps/backend/Simulation/Scenario/WorldBuilder.cs
Normal file
182
apps/backend/Simulation/Scenario/WorldBuilder.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user