Refactor world bootstrap and allow empty startup worlds

This commit is contained in:
2026-03-29 13:22:48 -04:00
parent 640e147ea8
commit 0bb72bee35
79 changed files with 173146 additions and 9235 deletions

View File

@@ -5,7 +5,6 @@ namespace SpaceGame.Api.Universe.Scenario;
internal static class LoaderSupport
{
internal const string DefaultFactionId = "sol-dominion";
internal const float MinimumFactionCredits = 0f;
internal const float MinimumRefineryOre = 0f;
internal const float MinimumRefineryStock = 0f;
@@ -97,7 +96,7 @@ internal static class LoaderSupport
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))
{
return;
throw new InvalidOperationException($"Module '{moduleId}' is not defined in static data.");
}
station.Modules.Add(StationModuleRuntime.Create($"{station.Id}-module-{station.Modules.Count + 1}", definition));

View File

@@ -0,0 +1,289 @@
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Ships.Simulation;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class ScenarioContentBuilder(
IStaticDataProvider staticData,
IBalanceService balance)
{
public ScenarioWorldContent Build(
ScenarioDefinition scenario,
WorldBuildTopology topology)
{
var stations = CreateStations(
scenario,
topology.SystemsById,
topology.SpatialLayout.SystemGraphs,
topology.SpatialLayout.Celestials);
var patrolRoutes = BuildPatrolRoutes(scenario, topology.SystemsById);
var ships = CreateShips(
scenario,
topology.SystemsById,
topology.SpatialLayout.Celestials,
patrolRoutes,
stations);
return new ScenarioWorldContent(stations, ships);
}
private List<StationRuntime> CreateStations(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials)
{
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!systemsById.TryGetValue(plan.SystemId, out var system))
{
throw new InvalidOperationException($"Scenario station '{plan.Label}' references unknown system '{plan.SystemId}'.");
}
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,
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
Position = placement.Position,
FactionId = GetRequiredFactionId(plan.FactionId, $"station '{plan.Label}'"),
CelestialId = placement.AnchorCelestial.Id,
Health = 600f,
MaxHealth = 600f,
};
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = BuildStartingModules(plan);
foreach (var moduleId in startingModules)
{
AddStationModule(station, staticData.ModuleDefinitions, moduleId);
}
}
return stations;
}
private IReadOnlyList<string> BuildStartingModules(InitialStationDefinition plan)
{
var startingModules = new List<string>(plan.StartingModules.Count > 0
? plan.StartingModules
: []);
EnsureStartingModule(startingModules, StarterStationLayoutResolver.ResolveDockModuleId(plan.FactionId, staticData.ModuleDefinitions));
var powerModuleId = StarterStationLayoutResolver.ResolvePowerModuleId(plan.FactionId, staticData.ModuleDefinitions);
EnsureStartingModule(startingModules, powerModuleId);
var defaultContainerStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
powerModuleId,
plan.FactionId,
staticData.ModuleDefinitions,
staticData.ItemDefinitions)
.FirstOrDefault(moduleId =>
{
return staticData.ModuleDefinitions.TryGetValue(moduleId, out var definition)
&& definition is StorageModuleDefinition storageDefinition
&& storageDefinition.StorageKind == StorageKind.Container;
});
if (defaultContainerStorageModuleId is not null)
{
EnsureStartingModule(startingModules, defaultContainerStorageModuleId);
}
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(plan.Objective, plan.FactionId, staticData.ModuleDefinitions);
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{
EnsureStartingModule(startingModules, objectiveModuleId);
if (!string.Equals(objectiveModuleId, powerModuleId, StringComparison.Ordinal))
{
EnsureStartingModule(startingModules, powerModuleId);
}
foreach (var storageModuleId in StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
objectiveModuleId,
plan.FactionId,
staticData.ModuleDefinitions,
staticData.ItemDefinitions))
{
EnsureStartingModule(startingModules, storageModuleId);
}
}
foreach (var moduleId in startingModules)
{
if (!staticData.ModuleDefinitions.ContainsKey(moduleId))
{
throw new InvalidOperationException($"Station '{plan.Label}' requires module '{moduleId}', but it is not defined in static data.");
}
}
return startingModules;
}
private static void EnsureStartingModule(List<string> modules, string moduleId)
{
if (!modules.Contains(moduleId, StringComparer.Ordinal))
{
modules.Add(moduleId);
}
}
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 List<ShipRuntime> CreateShips(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations)
{
var ships = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!staticData.ShipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
throw new InvalidOperationException($"Scenario ship formation references unknown ship '{formation.ShipId}'.");
}
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);
var factionId = GetRequiredFactionId(formation.FactionId, $"ship formation '{formation.ShipId}' in system '{formation.SystemId}'");
ships.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = factionId,
Position = position,
TargetPosition = position,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(formation.SystemId, position, celestials),
DefaultBehavior = CreateBehavior(
definition,
formation.SystemId,
factionId,
patrolRoutes,
stations),
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.MaxHealth,
});
foreach (var (itemId, amount) in formation.StartingInventory)
{
if (amount > 0f)
{
ships[^1].Inventory[itemId] = amount;
}
}
}
}
return ships;
}
private static string GetRequiredFactionId(string? factionId, string context)
{
if (!string.IsNullOrWhiteSpace(factionId))
{
return factionId;
}
throw new InvalidOperationException($"Scenario {context} is missing a factionId.");
}
private static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
string factionId,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations)
{
var homeStation = stations.FirstOrDefault(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal));
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
PreferredConstructionSiteId = null,
};
}
if (LoaderSupport.HasCapabilities(definition, "mining") && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
AreaSystemId = homeStation.SystemId,
MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1,
};
}
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime
{
Kind = "advanced-auto-trade",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
MaxSystemRange = 2,
};
}
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
PatrolPoints = route,
PatrolIndex = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = "idle",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
};
}
}

View File

@@ -12,16 +12,16 @@ public sealed class ScenarioLoader(IOptions<StaticDataOptions> staticDataOptions
PropertyNameCaseInsensitive = true,
};
public ScenarioDefinition? Load()
public ScenarioDefinition Load(string relativePath)
{
var scenarioPath = Path.Combine(staticDataOptions.Value.DataRoot, "scenario.json");
var scenarioPath = Path.Combine(staticDataOptions.Value.DataRoot, relativePath);
if (!File.Exists(scenarioPath))
{
return null;
throw new FileNotFoundException($"Scenario file was not found: {relativePath}", scenarioPath);
}
var json = File.ReadAllText(scenarioPath);
return JsonSerializer.Deserialize<ScenarioDefinition>(json, _jsonOptions)
?? throw new InvalidOperationException("Unable to read scenario.json.");
?? throw new InvalidOperationException($"Unable to read {relativePath}.");
}
}

View File

@@ -0,0 +1,111 @@
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class ScenarioValidationService(IStaticDataProvider staticData)
{
public ScenarioDefinition CreateEmptyScenario(
WorldGenerationOptions worldGenerationOptions,
IReadOnlyList<SolarSystemDefinition> systems)
{
if (systems.Count == 0)
{
throw new InvalidOperationException("World generation produced no systems.");
}
return new ScenarioDefinition
{
WorldGeneration = worldGenerationOptions,
InitialStations = [],
ShipFormations = [],
PatrolRoutes = [],
};
}
public void Validate(
ScenarioDefinition scenario,
IReadOnlySet<string> availableSystemIds)
{
foreach (var station in scenario.InitialStations)
{
ValidateSystemExists(station.SystemId, $"station '{station.Label}' system", availableSystemIds);
ValidateFactionId(station.FactionId, $"station '{station.Label}'");
foreach (var moduleId in station.StartingModules)
{
ValidateModuleId(moduleId, $"station '{station.Label}' starting module");
}
}
foreach (var formation in scenario.ShipFormations)
{
ValidateSystemExists(formation.SystemId, $"ship formation '{formation.ShipId}' system", availableSystemIds);
ValidateFactionId(formation.FactionId, $"ship formation '{formation.ShipId}' in system '{formation.SystemId}'");
ValidateShipId(formation.ShipId, $"ship formation in system '{formation.SystemId}'");
foreach (var itemId in formation.StartingInventory.Keys)
{
ValidateItemId(itemId, $"ship formation '{formation.ShipId}' starting inventory");
}
}
foreach (var route in scenario.PatrolRoutes)
{
ValidateSystemExists(route.SystemId, "patrol route system", availableSystemIds);
}
}
private static string GetRequiredFactionId(string? factionId, string context)
{
if (!string.IsNullOrWhiteSpace(factionId))
{
return factionId;
}
throw new InvalidOperationException($"Scenario {context} is missing a factionId.");
}
private static void ValidateSystemExists(
string systemId,
string context,
IReadOnlySet<string> availableSystemIds)
{
if (!availableSystemIds.Contains(systemId))
{
throw new InvalidOperationException($"Scenario {context} references unknown generated system '{systemId}'.");
}
}
private void ValidateFactionId(string? factionId, string context)
{
var requiredFactionId = GetRequiredFactionId(factionId, context);
if (!staticData.FactionDefinitions.ContainsKey(requiredFactionId))
{
throw new InvalidOperationException($"Scenario {context} references unknown faction '{requiredFactionId}'.");
}
}
private void ValidateModuleId(string moduleId, string context)
{
if (!staticData.ModuleDefinitions.ContainsKey(moduleId))
{
throw new InvalidOperationException($"Scenario {context} references unknown module '{moduleId}'.");
}
}
private void ValidateShipId(string shipId, string context)
{
if (!staticData.ShipDefinitions.ContainsKey(shipId))
{
throw new InvalidOperationException($"Scenario {context} references unknown ship '{shipId}'.");
}
}
private void ValidateItemId(string itemId, string context)
{
if (!staticData.ItemDefinitions.ContainsKey(itemId))
{
throw new InvalidOperationException($"Scenario {context} references unknown item '{itemId}'.");
}
}
}

View File

@@ -0,0 +1,5 @@
namespace SpaceGame.Api.Universe.Scenario;
public sealed record ScenarioWorldContent(
IReadOnlyList<StationRuntime> Stations,
IReadOnlyList<ShipRuntime> Ships);

View File

@@ -2,9 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class SpatialBuilder
public sealed class SpatialBuilder(IBalanceService balance)
{
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceOptions balance)
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems)
{
var systemGraphs = systems.ToDictionary(
system => system.Definition.Id,
@@ -305,12 +305,12 @@ public sealed class SpatialBuilder
}
}
internal sealed record ScenarioSpatialLayout(
public sealed record ScenarioSpatialLayout(
IReadOnlyDictionary<string, SystemSpatialGraph> SystemGraphs,
List<CelestialRuntime> Celestials,
List<ResourceNodeRuntime> Nodes);
internal sealed record SystemSpatialGraph(
public sealed record SystemSpatialGraph(
string SystemId,
List<CelestialRuntime> Celestials,
Dictionary<int, Dictionary<string, CelestialRuntime>> LagrangeNodesByPlanetIndex);

View File

@@ -0,0 +1,145 @@
using SpaceGame.Api.Shared.Runtime;
namespace SpaceGame.Api.Universe.Scenario;
internal static class StarterStationLayoutResolver
{
internal static string ResolveDockModuleId(
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions) =>
SelectPreferredModule(
moduleDefinitions.Values.Where(definition => definition.ModuleType == ModuleType.DockArea),
factionId,
"starter dock module").Id;
internal static string ResolvePowerModuleId(
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions) =>
ResolveProducerModuleId("energycells", factionId, moduleDefinitions);
internal static string? ResolveObjectiveModuleId(
string? objective,
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions)
{
var targetWareId = ResolveObjectiveWareId(objective);
return targetWareId is null ? null : ResolveProducerModuleId(targetWareId, factionId, moduleDefinitions);
}
internal static IEnumerable<string> ResolveRequiredStorageModuleIds(
string moduleId,
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
{
throw new InvalidOperationException($"Module '{moduleId}' is not defined in static data.");
}
foreach (var wareId in moduleDefinition.BuildRecipes
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
.Concat(moduleDefinition.ProductItemIds)
.Distinct(StringComparer.Ordinal))
{
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
{
throw new InvalidOperationException($"Module '{moduleId}' references unknown ware '{wareId}'.");
}
if (itemDefinition.CargoKind is not { } storageKind)
{
continue;
}
yield return ResolveStorageModuleId(storageKind, factionId, moduleDefinitions);
}
}
private static string? ResolveObjectiveWareId(string? objective) =>
StationSimulationService.NormalizeStationObjective(objective) switch
{
"power" => "energycells",
"refinery" => "refinedmetals",
"graphene" => "graphene",
"siliconwafers" => "siliconwafers",
"hullparts" => "hullparts",
"claytronics" => "claytronics",
"quantumtubes" => "quantumtubes",
"antimattercells" => "antimattercells",
"superfluidcoolant" => "superfluidcoolant",
"water" => "water",
_ => null,
};
private static string ResolveProducerModuleId(
string wareId,
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions) =>
SelectPreferredModule(
moduleDefinitions.Values
.OfType<ProductionModuleDefinition>()
.Where(definition => definition.ProductItemIds.Contains(wareId, StringComparer.Ordinal)),
factionId,
$"producer module for ware '{wareId}'").Id;
private static string ResolveStorageModuleId(
StorageKind storageKind,
string? factionId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions) =>
SelectPreferredModule(
moduleDefinitions.Values
.OfType<StorageModuleDefinition>()
.Where(definition => definition.StorageKind == storageKind),
factionId,
$"storage module for cargo kind '{storageKind.ToDataValue()}'").Id;
private static T SelectPreferredModule<T>(
IEnumerable<T> candidates,
string? factionId,
string context)
where T : ModuleDefinition
{
var ordered = candidates
.OrderBy(definition => ComputeOwnerRank(definition, factionId))
.ThenBy(definition => ComputeModuleRank(definition))
.ThenBy(definition => definition.Id, StringComparer.Ordinal)
.ToList();
return ordered.FirstOrDefault()
?? throw new InvalidOperationException($"Unable to resolve {context}.");
}
private static int ComputeOwnerRank(ModuleDefinition definition, string? factionId)
{
if (string.IsNullOrWhiteSpace(factionId))
{
return 1;
}
return definition.Owners.Contains(factionId, StringComparer.Ordinal) ? 0 : 1;
}
private static int ComputeModuleRank(ModuleDefinition definition)
{
if (definition.ModuleType is ModuleType.DockArea or ModuleType.Storage)
{
if (definition.Id.Contains("_m_", StringComparison.Ordinal))
{
return 0;
}
if (definition.Id.Contains("_s_", StringComparison.Ordinal))
{
return 1;
}
if (definition.Id.Contains("_l_", StringComparison.Ordinal))
{
return 2;
}
}
return 3;
}
}

View File

@@ -4,114 +4,65 @@ namespace SpaceGame.Api.Universe.Scenario;
public sealed class SystemGenerationService
{
internal List<SolarSystemDefinition> PrepareAuthoredSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems
private const float KnownSystemSelectionChance = 0.5f;
internal List<SolarSystemDefinition> PrepareKnownSystems(IReadOnlyList<SolarSystemDefinition> knownSystems) =>
knownSystems
.Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index))
.ToList();
internal List<SolarSystemDefinition> ExpandSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
int targetSystemCount)
internal List<SolarSystemDefinition> GenerateSystems(
IReadOnlyList<SolarSystemDefinition> knownSystems,
WorldGenerationOptions worldGenerationOptions)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (targetSystemCount <= 0)
if (worldGenerationOptions.TargetSystemCount <= 0)
{
return [];
}
if (systems.Count > targetSystemCount)
if (knownSystems.Count == 0)
{
return TrimSystemsToTarget(systems, targetSystemCount);
throw new InvalidOperationException("World generation requires at least one known system template.");
}
if (systems.Count >= targetSystemCount || authoredSystems.Count == 0)
{
return systems;
}
var systems = new List<SolarSystemDefinition>(worldGenerationOptions.TargetSystemCount);
var availableKnownSystems = knownSystems.Select(CloneSystemDefinition).ToList();
var templateSystems = knownSystems.Select(CloneSystemDefinition).ToList();
var existingIds = systems
.Select(system => system.Id)
.ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(
authoredSystems.Select(system => ToVector(system.Position)).ToList(),
targetSystemCount - systems.Count);
var existingIds = new HashSet<string>(StringComparer.Ordinal);
var occupiedPositions = new List<Vector3>();
var generatedSystemCount = 0;
for (var index = systems.Count; index < targetSystemCount; index += 1)
for (var slotIndex = 0; slotIndex < worldGenerationOptions.TargetSystemCount; slotIndex += 1)
{
var template = authoredSystems[index % authoredSystems.Count];
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, index + 1);
if (ShouldUseKnownSystem(worldGenerationOptions, slotIndex, availableKnownSystems.Count))
{
var knownSystemIndex = SelectKnownSystemIndex(worldGenerationOptions.Seed, slotIndex, availableKnownSystems.Count);
var knownSystem = availableKnownSystems[knownSystemIndex];
availableKnownSystems.RemoveAt(knownSystemIndex);
systems.Add(knownSystem);
existingIds.Add(knownSystem.Id);
occupiedPositions.Add(ToVector(knownSystem.Position));
continue;
}
var template = templateSystems[generatedSystemCount % templateSystems.Count];
var name = GeneratedSystemNames[generatedSystemCount % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, generatedSystemCount + 1);
while (!existingIds.Add(id))
{
id = $"{id}-x";
}
systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count]));
var position = BuildGeneratedSystemPosition(occupiedPositions, generatedSystemCount);
systems.Add(CreateGeneratedSystem(template, name, id, generatedSystemCount, position));
occupiedPositions.Add(position);
generatedSystemCount += 1;
}
return systems;
}
private static List<SolarSystemDefinition> TrimSystemsToTarget(IReadOnlyList<SolarSystemDefinition> systems, int targetSystemCount)
{
var selected = new List<SolarSystemDefinition>(targetSystemCount);
void AddById(string systemId)
{
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)))
{
selected.Add(system);
}
}
foreach (var preferredSystemId in SystemSelectionPolicy.PreferredSystemIds)
{
AddById(preferredSystemId);
}
foreach (var system in systems)
{
if (selected.Count >= targetSystemCount)
{
break;
}
if (selected.Any(candidate => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
{
continue;
}
selected.Add(system);
}
if (selected.Count > 0 && selected.Count <= 4)
{
ApplyCompactGalaxyLayout(selected);
}
return selected;
}
private static void ApplyCompactGalaxyLayout(IReadOnlyList<SolarSystemDefinition> systems)
{
var compactPositions = new[]
{
new[] { 0f, 0f, 0f },
new[] { 2.6f, 0.02f, -0.42f },
new[] { -2.4f, -0.04f, 0.56f },
new[] { 0.52f, 0.04f, 2.48f },
};
for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1)
{
systems[index].Position = compactPositions[index];
}
}
private static SolarSystemDefinition CreateGeneratedSystem(
SolarSystemDefinition template,
string label,
@@ -260,30 +211,38 @@ public sealed class SystemGenerationService
return system;
}
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
private static Vector3 BuildGeneratedSystemPosition(IReadOnlyCollection<Vector3> occupiedPositions, int generatedIndex)
{
var allPositions = occupiedPositions.ToList();
var generated = new List<Vector3>(count);
for (var index = 0; index < count; index += 1)
for (var attempt = 0; attempt < 64; attempt += 1)
{
Vector3? accepted = null;
for (var attempt = 0; attempt < 64; attempt += 1)
var candidate = ComputeGeneratedSystemPosition(generatedIndex, attempt);
if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
accepted = candidate;
break;
}
return candidate;
}
accepted ??= ComputeFallbackGeneratedSystemPosition(index);
generated.Add(accepted.Value);
allPositions.Add(accepted.Value);
}
return generated;
return ComputeFallbackGeneratedSystemPosition(generatedIndex);
}
private static bool ShouldUseKnownSystem(
WorldGenerationOptions worldGenerationOptions,
int slotIndex,
int remainingKnownSystemCount)
{
if (!worldGenerationOptions.UseKnownSystems || remainingKnownSystemCount <= 0)
{
return false;
}
return Hash01(worldGenerationOptions.Seed, 700 + slotIndex) >= KnownSystemSelectionChance;
}
private static int SelectKnownSystemIndex(int seed, int slotIndex, int remainingKnownSystemCount)
{
var selection = Hash01(seed, 900 + slotIndex);
return Math.Min((int)(selection * remainingKnownSystemCount), remainingKnownSystemCount - 1);
}
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)

View File

@@ -1,23 +0,0 @@
namespace SpaceGame.Api.Universe.Scenario;
internal static class SystemSelectionPolicy
{
internal static readonly string[] PreferredSystemIds =
[
"sol",
"helios",
];
internal static string SelectFallbackSystemId(IReadOnlyList<string> availableSystemIds)
{
foreach (var preferredSystemId in PreferredSystemIds)
{
if (availableSystemIds.Contains(preferredSystemId, StringComparer.Ordinal))
{
return preferredSystemId;
}
}
return availableSystemIds.FirstOrDefault() ?? string.Empty;
}
}

View File

@@ -0,0 +1,7 @@
namespace SpaceGame.Api.Universe.Scenario;
public sealed record WorldBuildTopology(
IReadOnlyList<SolarSystemDefinition> Systems,
IReadOnlyList<SystemRuntime> SystemRuntimes,
IReadOnlyDictionary<string, SystemRuntime> SystemsById,
ScenarioSpatialLayout SpatialLayout);

View File

@@ -1,383 +1,26 @@
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
using SpaceGame.Api.Universe.Bootstrap;
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class WorldBuilder(
StaticDataCatalog staticData,
IOptions<BalanceOptions> balance,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder,
WorldSeedingService seedingService)
WorldTopologyBuilder topologyBuilder,
ScenarioValidationService scenarioValidationService,
ScenarioContentBuilder contentBuilder,
WorldRuntimeAssembler runtimeAssembler)
{
public SimulationWorld Build(
GameStartOptionsDefinition gameStartOptions,
public SimulationWorld BuildFromGeneration(WorldGenerationOptions worldGenerationOptions) =>
BuildWorld(worldGenerationOptions, null);
public SimulationWorld BuildFromScenario(ScenarioDefinition scenarioDefinition) =>
BuildWorld(scenarioDefinition.WorldGeneration, scenarioDefinition);
private SimulationWorld BuildWorld(
WorldGenerationOptions worldGenerationOptions,
ScenarioDefinition? scenarioDefinition)
{
var systems = generationService.ExpandSystems(
generationService.PrepareAuthoredSystems(authoredSystems),
gameStartOptions.WorldGeneration.TargetSystemCount);
var topology = topologyBuilder.Build(worldGenerationOptions);
var scenario = scenarioDefinition ?? scenarioValidationService.CreateEmptyScenario(worldGenerationOptions, topology.Systems);
scenarioValidationService.Validate(scenario, topology.Systems.Select(system => system.Id).ToHashSet(StringComparer.Ordinal));
var scenario = NormalizeScenarioToAvailableSystems(
scenarioDefinition,
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, balance.Value);
var stations = CreateStations(
scenario,
systemsById,
spatialLayout.SystemGraphs,
spatialLayout.Celestials,
staticData.ModuleDefinitions,
staticData.ItemDefinitions);
seedingService.InitializeStationStockpiles(stations, staticData.ModuleDefinitions);
var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById);
var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, staticData.ShipDefinitions, patrolRoutes, stations, refinery);
if (gameStartOptions.WorldGeneration.AiControllerFactionCount < int.MaxValue)
{
var aiFactionIds = stations
.Select(s => s.FactionId)
.Concat(ships.Select(s => s.FactionId))
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal)
.Take(gameStartOptions.WorldGeneration.AiControllerFactionCount)
.ToHashSet(StringComparer.Ordinal);
aiFactionIds.Add(DefaultFactionId);
stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
ships = ships.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
}
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 playerFaction = gameStartOptions.WorldGeneration.GeneratePlayerFaction
? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc)
: null;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var world = new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = gameStartOptions.Seed,
Systems = systemRuntimes,
Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes,
Wrecks = [],
Stations = stations,
Ships = ships,
Factions = factions,
PlayerFaction = playerFaction,
Geopolitics = null,
Commanders = commanders,
Claims = claims,
ConstructionSites = [],
MarketOrders = [],
Policies = policies,
ShipDefinitions = new Dictionary<string, ShipDefinition>(staticData.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(staticData.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(staticData.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(staticData.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(staticData.Recipes, StringComparer.Ordinal),
ProductionGraph = staticData.ProductionGraph,
OrbitalTimeSeconds = gameStartOptions.Seed * 97d,
GeneratedAtUtc = nowUtc,
};
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(world);
world.ConstructionSites.AddRange(constructionSites);
world.MarketOrders.AddRange(marketOrders);
var geopolitics = new GeopoliticalSimulationService();
geopolitics.Update(world, 0f, []);
return world;
}
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition? scenario,
IReadOnlyList<string> availableSystemIds)
{
var fallbackSystemId = SystemSelectionPolicy.SelectFallbackSystemId(availableSystemIds);
if (scenario is null)
{
return new ScenarioDefinition
{
GameStartOptions = new GameStartOptionsDefinition(),
InitialStations = [],
ShipFormations = [],
PatrolRoutes = [],
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = fallbackSystemId,
RefinerySystemId = fallbackSystemId,
},
};
}
if (availableSystemIds.Count == 0)
{
return scenario;
}
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
GameStartOptions = scenario.GameStartOptions,
InitialStations = scenario.InitialStations
.Select(station => new InitialStationDefinition
{
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
Objective = station.Objective,
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,
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
})
.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 List<StationRuntime> CreateStations(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyDictionary<string, SystemSpatialGraph> systemGraphs,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{
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,
Objective = StationSimulationService.NormalizeStationObjective(plan.Objective),
Position = placement.Position,
FactionId = plan.FactionId ?? DefaultFactionId,
CelestialId = placement.AnchorCelestial.Id,
Health = 600f,
MaxHealth = 600f,
};
stations.Add(station);
placement.AnchorCelestial.OccupyingStructureId = station.Id;
var startingModules = BuildStartingModules(plan, moduleDefinitions, itemDefinitions);
foreach (var moduleId in startingModules)
{
AddStationModule(station, moduleDefinitions, moduleId);
}
}
return stations;
}
private static IReadOnlyList<string> BuildStartingModules(
InitialStationDefinition plan,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{
var startingModules = new List<string>(plan.StartingModules.Count > 0
? plan.StartingModules
: ["module_arg_dock_m_01_lowtech", "module_gen_prod_energycells_01", "module_arg_stor_container_m_01"]);
EnsureStartingModule(startingModules, "module_arg_dock_m_01_lowtech");
var objectiveModuleId = GetObjectiveStartingModuleId(plan.Objective);
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{
EnsureStartingModule(startingModules, objectiveModuleId);
if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal))
{
EnsureStartingModule(startingModules, "module_gen_prod_energycells_01");
}
foreach (var storageModuleId in GetRequiredStartingStorageModules(objectiveModuleId, moduleDefinitions, itemDefinitions))
{
EnsureStartingModule(startingModules, storageModuleId);
}
}
return startingModules;
}
private static string? GetObjectiveStartingModuleId(string? objective) =>
StationSimulationService.NormalizeStationObjective(objective) switch
{
"power" => "module_gen_prod_energycells_01",
"refinery" => "module_gen_ref_ore_01",
"graphene" => "module_gen_prod_graphene_01",
"siliconwafers" => "module_gen_prod_siliconwafers_01",
"hullparts" => "module_gen_prod_hullparts_01",
"claytronics" => "module_gen_prod_claytronics_01",
"quantumtubes" => "module_gen_prod_quantumtubes_01",
"antimattercells" => "module_gen_prod_antimattercells_01",
"superfluidcoolant" => "module_gen_prod_superfluidcoolant_01",
"water" => "module_gen_prod_water_01",
_ => null,
};
private static IEnumerable<string> GetRequiredStartingStorageModules(
string moduleId,
IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions,
IReadOnlyDictionary<string, ItemDefinition> itemDefinitions)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
{
yield break;
}
foreach (var wareId in moduleDefinition.BuildRecipes
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
.Concat(moduleDefinition.ProductItemIds)
.Distinct(StringComparer.Ordinal))
{
if (!itemDefinitions.TryGetValue(wareId, out var itemDefinition))
{
continue;
}
if (SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport.GetStorageRequirement(moduleDefinitions, itemDefinition.CargoKind) is { } storageModuleId)
{
yield return storageModuleId;
}
}
}
private static void EnsureStartingModule(List<string> modules, string moduleId)
{
if (!modules.Contains(moduleId, StringComparer.Ordinal))
{
modules.Add(moduleId);
}
}
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 List<ShipRuntime> CreateShips(
ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
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.Value.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,
formation.FactionId ?? DefaultFactionId,
scenario,
patrolRoutes,
stations,
refinery),
Skills = WorldSeedingService.CreateSkills(definition),
Health = definition.MaxHealth,
});
foreach (var (itemId, amount) in formation.StartingInventory)
{
if (amount > 0f)
{
ships[^1].Inventory[itemId] = amount;
}
}
}
}
return ships;
var content = contentBuilder.Build(scenario, topology);
return runtimeAssembler.Assemble(worldGenerationOptions, topology, content);
}
}

View File

@@ -0,0 +1,62 @@
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class WorldRuntimeAssembler(
IStaticDataProvider staticData,
WorldSeedingService seedingService)
{
public SimulationWorld Assemble(
WorldGenerationOptions worldGenerationOptions,
WorldBuildTopology topology,
ScenarioWorldContent content)
{
seedingService.InitializeStationStockpiles(content.Stations, staticData.ModuleDefinitions);
var factions = seedingService.CreateFactions(content.Stations, content.Ships);
seedingService.BootstrapFactionEconomy(factions, content.Stations);
var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, content.Stations, content.Ships);
var nowUtc = DateTimeOffset.UtcNow;
var playerFaction = worldGenerationOptions.GeneratePlayerFaction
? seedingService.CreatePlayerFaction(factions, content.Stations, content.Ships, commanders, policies, nowUtc)
: null;
var claims = seedingService.CreateClaims(content.Stations, topology.SpatialLayout.Celestials, nowUtc);
var world = new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = worldGenerationOptions.Seed,
Systems = topology.SystemRuntimes.ToList(),
Celestials = topology.SpatialLayout.Celestials,
Nodes = topology.SpatialLayout.Nodes,
Wrecks = [],
Stations = content.Stations.ToList(),
Ships = content.Ships.ToList(),
Factions = factions,
PlayerFaction = playerFaction,
Geopolitics = null,
Commanders = commanders,
Claims = claims,
ConstructionSites = [],
MarketOrders = [],
Policies = policies,
ShipDefinitions = new Dictionary<string, ShipDefinition>(staticData.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(staticData.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(staticData.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(staticData.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(staticData.Recipes, StringComparer.Ordinal),
ProductionGraph = staticData.ProductionGraph,
OrbitalTimeSeconds = worldGenerationOptions.Seed * 97d,
GeneratedAtUtc = nowUtc,
};
var (constructionSites, marketOrders) = seedingService.CreateConstructionSites(world);
world.ConstructionSites.AddRange(constructionSites);
world.MarketOrders.AddRange(marketOrders);
var geopolitics = new GeopoliticalSimulationService();
geopolitics.Update(world, 0f, []);
return world;
}
}

View File

@@ -1,8 +1,9 @@
using SpaceGame.Api.Universe.Bootstrap;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class WorldSeedingService
public sealed class WorldSeedingService(IStaticDataProvider staticData)
{
internal List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,
@@ -18,7 +19,7 @@ public sealed class WorldSeedingService
if (factionIds.Count == 0)
{
factionIds.Add(DefaultFactionId);
return [];
}
return factionIds.Select(CreateFaction).ToList();
@@ -70,15 +71,6 @@ public sealed class WorldSeedingService
}
}
internal StationRuntime? SelectRefineryStation(IReadOnlyCollection<StationRuntime> stations, ScenarioDefinition scenario)
{
return stations.FirstOrDefault(station =>
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal) &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault(station =>
string.Equals(StationSimulationService.DetermineStationRole(station), "refinery", StringComparison.Ordinal));
}
internal List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<CelestialRuntime> celestials,
@@ -183,39 +175,32 @@ public sealed class WorldSeedingService
private static bool HasSatisfiedStarterObjectiveLayout(SimulationWorld world, StationRuntime station)
{
var role = StationSimulationService.DetermineStationRole(station);
var objectiveModuleId = role switch
{
"power" => "module_gen_prod_energycells_01",
"refinery" => "module_gen_prod_refinedmetals_01",
"graphene" => "module_gen_prod_graphene_01",
"siliconwafers" => "module_gen_prod_siliconwafers_01",
"hullparts" => "module_gen_prod_hullparts_01",
"claytronics" => "module_gen_prod_claytronics_01",
"quantumtubes" => "module_gen_prod_quantumtubes_01",
"antimattercells" => "module_gen_prod_antimattercells_01",
"superfluidcoolant" => "module_gen_prod_superfluidcoolant_01",
"water" => "module_gen_prod_water_01",
_ => null,
};
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(role, station.FactionId, world.ModuleDefinitions);
if (objectiveModuleId is null)
{
return false;
}
if (!station.InstalledModules.Contains("module_arg_dock_m_01_lowtech", StringComparer.Ordinal)
var requiredDockModuleId = StarterStationLayoutResolver.ResolveDockModuleId(station.FactionId, world.ModuleDefinitions);
if (!station.InstalledModules.Contains(requiredDockModuleId, StringComparer.Ordinal)
|| !station.InstalledModules.Contains(objectiveModuleId, StringComparer.Ordinal))
{
return false;
}
if (!string.Equals(objectiveModuleId, "module_gen_prod_energycells_01", StringComparison.Ordinal)
&& !station.InstalledModules.Contains("module_gen_prod_energycells_01", StringComparer.Ordinal))
var powerModuleId = StarterStationLayoutResolver.ResolvePowerModuleId(station.FactionId, world.ModuleDefinitions);
if (!string.Equals(objectiveModuleId, powerModuleId, StringComparison.Ordinal)
&& !station.InstalledModules.Contains(powerModuleId, StringComparer.Ordinal))
{
return false;
}
foreach (var storageModuleId in GetRequiredStorageModulesForInstalledObjective(world, objectiveModuleId))
foreach (var storageModuleId in StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
objectiveModuleId,
station.FactionId,
world.ModuleDefinitions,
world.ItemDefinitions))
{
if (!station.InstalledModules.Contains(storageModuleId, StringComparer.Ordinal))
{
@@ -226,30 +211,6 @@ public sealed class WorldSeedingService
return true;
}
private static IEnumerable<string> GetRequiredStorageModulesForInstalledObjective(SimulationWorld world, string moduleId)
{
if (!world.ModuleDefinitions.TryGetValue(moduleId, out var moduleDefinition))
{
yield break;
}
foreach (var wareId in moduleDefinition.BuildRecipes
.SelectMany(production => production.Wares.Select(ware => ware.ItemId))
.Concat(moduleDefinition.ProductItemIds)
.Distinct(StringComparer.Ordinal))
{
if (!world.ItemDefinitions.TryGetValue(wareId, out var itemDefinition))
{
continue;
}
if (SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport.GetStorageRequirement(world.ModuleDefinitions, itemDefinition.CargoKind) is { } storageModuleId)
{
yield return storageModuleId;
}
}
}
internal List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{
var policies = new List<PolicySetRuntime>(factions.Count);
@@ -355,8 +316,8 @@ public sealed class WorldSeedingService
IReadOnlyCollection<PolicySetRuntime> policies,
DateTimeOffset nowUtc)
{
var sovereignFaction = factions.FirstOrDefault(faction => string.Equals(faction.Id, DefaultFactionId, StringComparison.Ordinal))
?? factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First();
var sovereignFaction = factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
?? throw new InvalidOperationException("Cannot create a player faction without at least one faction in the world.");
var player = new PlayerFactionRuntime
{
@@ -434,122 +395,55 @@ public sealed class WorldSeedingService
return player;
}
internal static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
string factionId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations,
StationRuntime? refinery)
private FactionRuntime CreateFaction(string factionId)
{
var homeStation = stations.FirstOrDefault(station =>
string.Equals(station.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(station.SystemId, systemId, StringComparison.Ordinal))
?? stations.FirstOrDefault(station => string.Equals(station.FactionId, factionId, StringComparison.Ordinal))
?? refinery;
if (string.Equals(definition.Kind, "construction", StringComparison.Ordinal) && homeStation is not null)
if (!staticData.FactionDefinitions.TryGetValue(factionId, out var definition))
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
PreferredConstructionSiteId = null,
};
throw new InvalidOperationException($"Faction '{factionId}' is not defined in static data.");
}
if (HasCapabilities(definition, "mining") && homeStation is not null)
return new FactionRuntime
{
return new DefaultBehaviorRuntime
{
Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1,
};
}
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime
{
Kind = "advanced-auto-trade",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
MaxSystemRange = 2,
};
}
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
PatrolPoints = route,
PatrolIndex = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = "idle",
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
Id = definition.Id,
Label = definition.Label,
Color = ResolveFactionColor(definition),
Credits = MinimumFactionCredits,
};
}
internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition)
{
return definition.Kind switch
{
"transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 },
"construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 },
"military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 },
_ when HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 },
_ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 },
};
}
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
"asterion-league" => new FactionRuntime
{
Id = factionId,
Label = "Asterion League",
Color = "#ff8f70",
Credits = MinimumFactionCredits,
},
"nadir-syndicate" => new FactionRuntime
{
Id = factionId,
Label = "Nadir Syndicate",
Color = "#91e6a8",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static string ResolveFactionColor(FactionDefinition definition) =>
definition.Id switch
{
"alliance" => "#c084fc",
"antigone" => "#f97316",
"argon" => "#3b82f6",
"boron" => "#14b8a6",
"freesplit" => "#ef4444",
"hatikvah" => "#84cc16",
"holyorder" => "#d97706",
"loanshark" => "#f59e0b",
"ministry" => "#a3e635",
"paranid" => "#eab308",
"pioneers" => "#60a5fa",
"scaleplate" => "#94a3b8",
"scavenger" => "#64748b",
"split" => "#b91c1c",
"teladi" => "#22c55e",
"terran" => "#38bdf8",
"trinity" => "#2dd4bf",
"xenon" => "#9ca3af",
_ => definition.RaceId switch
{
"argon" => "#3b82f6",
"boron" => "#14b8a6",
"paranid" => "#eab308",
"split" => "#b91c1c",
"teladi" => "#22c55e",
"terran" => "#38bdf8",
"xenon" => "#9ca3af",
_ => "#94a3b8",
},
};
private static void InitializeStationPopulation(
StationRuntime station,
@@ -563,12 +457,4 @@ public sealed class WorldSeedingService
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..]));
}
}

View File

@@ -0,0 +1,29 @@
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Universe.Scenario;
public sealed class WorldTopologyBuilder(
IStaticDataProvider staticData,
SystemGenerationService generationService,
SpatialBuilder spatialBuilder)
{
public WorldBuildTopology Build(WorldGenerationOptions worldGenerationOptions)
{
var systems = generationService.GenerateSystems(
generationService.PrepareKnownSystems(staticData.KnownSystems),
worldGenerationOptions);
var systemRuntimes = systems
.Select(definition => new SystemRuntime
{
Definition = definition,
Position = LoaderSupport.ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal);
var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes);
return new WorldBuildTopology(systems, systemRuntimes, systemsById, spatialLayout);
}
}