diff --git a/apps/backend/Simulation/Scenario/DataCatalogLoader.cs b/apps/backend/Simulation/Scenario/DataCatalogLoader.cs new file mode 100644 index 0000000..fb4671e --- /dev/null +++ b/apps/backend/Simulation/Scenario/DataCatalogLoader.cs @@ -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>("systems.json"); + var scenario = Read("scenario.json"); + var modules = NormalizeModules(Read>("modules.json")); + var ships = Read>("ships.json"); + var items = NormalizeItems(Read>("items.json")); + var balance = Read("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 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(string fileName) + { + var path = Path.Combine(dataRoot, fileName); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, _jsonOptions) + ?? throw new InvalidOperationException($"Unable to read {fileName}."); + } + + private static List BuildModuleRecipes(IEnumerable 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 BuildRecipes(IEnumerable items, IEnumerable ships, IReadOnlyCollection modules) + { + var recipes = new List(); + 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 InferRequiredModules(ItemDefinition item, IReadOnlyDictionary 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 NormalizeItems(List 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 NormalizeModules(List 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 AuthoredSystems, + ScenarioDefinition Scenario, + BalanceDefinition Balance, + IReadOnlyDictionary ModuleDefinitions, + IReadOnlyDictionary ShipDefinitions, + IReadOnlyDictionary ItemDefinitions, + IReadOnlyDictionary Recipes, + IReadOnlyDictionary ModuleRecipes); diff --git a/apps/backend/Simulation/Scenario/LoaderSupport.cs b/apps/backend/Simulation/Scenario/LoaderSupport.cs new file mode 100644 index 0000000..a3dc5d3 --- /dev/null +++ b/apps/backend/Simulation/Scenario/LoaderSupport.cs @@ -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 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 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 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 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); +} diff --git a/apps/backend/Simulation/Scenario/ScenarioLoader.cs b/apps/backend/Simulation/Scenario/ScenarioLoader.cs new file mode 100644 index 0000000..285fdf0 --- /dev/null +++ b/apps/backend/Simulation/Scenario/ScenarioLoader.cs @@ -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(); +} diff --git a/apps/backend/Simulation/ScenarioLoader.Spatial.cs b/apps/backend/Simulation/Scenario/SpatialBuilder.cs similarity index 72% rename from apps/backend/Simulation/ScenarioLoader.Spatial.cs rename to apps/backend/Simulation/Scenario/SpatialBuilder.cs index 5350a3f..1a1054c 100644 --- a/apps/backend/Simulation/ScenarioLoader.Spatial.cs +++ b/apps/backend/Simulation/Scenario/SpatialBuilder.cs @@ -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 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(); + 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) { var celestials = new List(); @@ -96,9 +134,7 @@ public sealed partial class ScenarioLoader return celestial; } - private static IEnumerable EnumeratePlanetLagrangePoints( - Vector3 planetPosition, - PlanetDefinition planet) + private static IEnumerable EnumeratePlanetLagrangePoints(Vector3 planetPosition, PlanetDefinition planet) { var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); var tangential = new Vector3(-radial.Z, 0f, radial.X); @@ -129,7 +165,6 @@ public sealed partial class ScenarioLoader 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) { var earthRadiusRatio = MathF.Max(planet.Size / 6371f, 0.05f); @@ -146,7 +181,7 @@ public sealed partial class ScenarioLoader return earthMasses / 332_946f; } - private static StationPlacement ResolveStationPlacement( + internal static StationPlacement ResolveStationPlacement( InitialStationDefinition plan, SystemRuntime system, SystemSpatialGraph graph, @@ -166,19 +201,19 @@ public sealed partial class ScenarioLoader { var targetPosition = NormalizeScenarioPoint(system, plan.Position); var preferredCelestial = existingCelestials - .Where((c) => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) - .OrderBy((c) => c.Position.DistanceTo(targetPosition)) + .Where(c => c.SystemId == system.Definition.Id && c.Kind == SpatialNodeKind.LagrangePoint) + .OrderBy(c => c.Position.DistanceTo(targetPosition)) .FirstOrDefault() ?? existingCelestials - .Where((c) => c.SystemId == system.Definition.Id) - .OrderBy((c) => c.Position.DistanceTo(targetPosition)) + .Where(c => c.SystemId == system.Definition.Id) + .OrderBy(c => c.Position.DistanceTo(targetPosition)) .First(); return new StationPlacement(preferredCelestial, preferredCelestial.Position); } var fallbackCelestial = graph.Celestials - .FirstOrDefault((c) => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) - ?? graph.Celestials.First((c) => c.Kind == SpatialNodeKind.Planet); + .FirstOrDefault(c => c.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(c.OccupyingStructureId)) + ?? graph.Celestials.First(c => c.Kind == SpatialNodeKind.Planet); return new StationPlacement(fallbackCelestial, fallbackCelestial.Position); } @@ -199,11 +234,11 @@ public sealed partial class ScenarioLoader if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0) { 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}"; - 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) @@ -226,9 +261,7 @@ public sealed partial class ScenarioLoader { var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); var orbitRadiusKm = SimulationUnits.AuToKilometers(planet.OrbitRadius); - var x = MathF.Cos(angle) * orbitRadiusKm; - var z = MathF.Sin(angle) * orbitRadiusKm; - return new Vector3(x, 0f, z); + return new Vector3(MathF.Cos(angle) * orbitRadiusKm, 0f, MathF.Sin(angle) * orbitRadiusKm); } private static Vector3 ComputeMoonPosition(Vector3 planetPosition, MoonDefinition moon) @@ -238,11 +271,11 @@ public sealed partial class ScenarioLoader return Add(planetPosition, local); } - private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection celestials) + internal static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection celestials) { var nearestCelestial = celestials - .Where((c) => c.SystemId == systemId) - .OrderBy((c) => c.Position.DistanceTo(position)) + .Where(c => c.SystemId == systemId) + .OrderBy(c => c.Position.DistanceTo(position)) .FirstOrDefault(); return new ShipSpatialStateRuntime @@ -255,13 +288,18 @@ public sealed partial class ScenarioLoader MovementRegime = MovementRegimeKinds.LocalFlight, }; } - - private sealed record SystemSpatialGraph( - string SystemId, - List Celestials, - Dictionary> LagrangeNodesByPlanetIndex); - - private sealed record LagrangePointPlacement(string Designation, Vector3 Position); - - private sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position); } + +internal sealed record ScenarioSpatialLayout( + IReadOnlyDictionary SystemGraphs, + List Celestials, + List Nodes); + +internal sealed record SystemSpatialGraph( + string SystemId, + List Celestials, + Dictionary> LagrangeNodesByPlanetIndex); + +internal sealed record LagrangePointPlacement(string Designation, Vector3 Position); + +internal sealed record StationPlacement(CelestialRuntime AnchorCelestial, Vector3 Position); diff --git a/apps/backend/Simulation/ScenarioLoader.Generation.cs b/apps/backend/Simulation/Scenario/SystemGenerationService.cs similarity index 81% rename from apps/backend/Simulation/ScenarioLoader.Generation.cs rename to apps/backend/Simulation/Scenario/SystemGenerationService.cs index 8689e15..d5f9a4a 100644 --- a/apps/backend/Simulation/ScenarioLoader.Generation.cs +++ b/apps/backend/Simulation/Scenario/SystemGenerationService.cs @@ -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 DevelopmentCompanionSystemId = "helios"; - private static List InjectSpecialSystems( - IReadOnlyList authoredSystems) - { - return authoredSystems + internal List InjectSpecialSystems(IReadOnlyList authoredSystems) => + authoredSystems .Select(CloneSystemDefinition) .ToList(); - } - private static List ExpandSystems( + internal List ExpandSystems( IReadOnlyList authoredSystems, int targetSystemCount) { @@ -39,10 +38,10 @@ public sealed partial class ScenarioLoader } var existingIds = systems - .Select((system) => system.Id) + .Select(system => system.Id) .ToHashSet(StringComparer.Ordinal); var generatedPositions = BuildGalaxyPositions( - authoredSystems.Select((system) => ToVector(system.Position)).ToList(), + authoredSystems.Select(system => ToVector(system.Position)).ToList(), targetSystemCount - systems.Count); for (var index = systems.Count; index < targetSystemCount; index += 1) @@ -61,16 +60,14 @@ public sealed partial class ScenarioLoader return systems; } - private static List TrimSystemsToTarget( - IReadOnlyList systems, - int targetSystemCount) + private static List TrimSystemsToTarget(IReadOnlyList systems, int targetSystemCount) { var selected = new List(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))) + 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); } @@ -86,7 +83,7 @@ public sealed partial class ScenarioLoader 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; } @@ -127,9 +124,8 @@ public sealed partial class ScenarioLoader { var starProfile = SelectStarProfile(generatedIndex); var planets = BuildGeneratedPlanets(template, generatedIndex); - var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex) - .Select((node) => new ResourceNodeDefinition + .Select(node => new ResourceNodeDefinition { SourceKind = node.SourceKind, Angle = node.Angle, @@ -185,40 +181,36 @@ public sealed partial class ScenarioLoader RadiusVariance = definition.AsteroidField.RadiusVariance, HeightVariance = definition.AsteroidField.HeightVariance, }, - ResourceNodes = definition.ResourceNodes - .Select((node) => new ResourceNodeDefinition - { - SourceKind = node.SourceKind, - Angle = node.Angle, - RadiusOffset = node.RadiusOffset, - InclinationDegrees = node.InclinationDegrees, - AnchorPlanetIndex = node.AnchorPlanetIndex, - AnchorMoonIndex = node.AnchorMoonIndex, - OreAmount = node.OreAmount, - ItemId = node.ItemId, - ShardCount = node.ShardCount, - }) - .ToList(), - Planets = definition.Planets - .Select((planet) => new PlanetDefinition - { - Label = planet.Label, - PlanetType = planet.PlanetType, - Shape = planet.Shape, - 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(), - OrbitRadius = planet.OrbitRadius, - OrbitSpeed = planet.OrbitSpeed, - OrbitEccentricity = planet.OrbitEccentricity, - OrbitInclination = planet.OrbitInclination, - OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, - OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, - OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, - Size = planet.Size, - Color = planet.Color, - Tilt = planet.Tilt, - HasRing = planet.HasRing, - }) - .ToList(), + ResourceNodes = definition.ResourceNodes.Select(node => new ResourceNodeDefinition + { + SourceKind = node.SourceKind, + Angle = node.Angle, + RadiusOffset = node.RadiusOffset, + InclinationDegrees = node.InclinationDegrees, + AnchorPlanetIndex = node.AnchorPlanetIndex, + AnchorMoonIndex = node.AnchorMoonIndex, + OreAmount = node.OreAmount, + ItemId = node.ItemId, + ShardCount = node.ShardCount, + }).ToList(), + Planets = definition.Planets.Select(planet => new PlanetDefinition + { + Label = planet.Label, + PlanetType = planet.PlanetType, + Shape = planet.Shape, + 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(), + OrbitRadius = planet.OrbitRadius, + OrbitSpeed = planet.OrbitSpeed, + OrbitEccentricity = planet.OrbitEccentricity, + OrbitInclination = planet.OrbitInclination, + OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode, + OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis, + OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch, + Size = planet.Size, + Color = planet.Color, + Tilt = planet.Tilt, + HasRing = planet.HasRing, + }).ToList(), }; } @@ -230,7 +222,7 @@ public sealed partial class ScenarioLoader var nodes = new List(); if (template.ResourceNodes.Count > 0) { - nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition + nodes.AddRange(template.ResourceNodes.Select(node => new ResourceNodeDefinition { SourceKind = node.SourceKind, Angle = node.Angle, @@ -259,7 +251,7 @@ public sealed partial class ScenarioLoader for (var attempt = 0; attempt < 64; attempt += 1) { var candidate = ComputeGeneratedSystemPosition(index, attempt); - if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) + if (allPositions.All(existing => existing.DistanceTo(candidate) >= MinimumSystemSeparation)) { accepted = candidate; break; @@ -307,7 +299,7 @@ public sealed partial class ScenarioLoader { var slug = string.Concat(label .ToLowerInvariant() - .Select((character) => char.IsLetterOrDigit(character) ? character : '-')) + .Select(character => char.IsLetterOrDigit(character) ? character : '-')) .Trim('-'); return $"gen-{ordinal}-{slug}"; @@ -359,9 +351,7 @@ public sealed partial class ScenarioLoader return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1); } - private static List BuildGeneratedPlanets( - SolarSystemDefinition template, - int generatedIndex) + private static List BuildGeneratedPlanets(SolarSystemDefinition template, int generatedIndex) { var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); var planets = new List(planetCount); @@ -495,23 +485,4 @@ public sealed partial class ScenarioLoader 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); - } - } diff --git a/apps/backend/Simulation/Scenario/WorldBuilder.cs b/apps/backend/Simulation/Scenario/WorldBuilder.cs new file mode 100644 index 0000000..d64e215 --- /dev/null +++ b/apps/backend/Simulation/Scenario/WorldBuilder.cs @@ -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(catalog.ShipDefinitions, StringComparer.Ordinal), + ItemDefinitions = new Dictionary(catalog.ItemDefinitions, StringComparer.Ordinal), + ModuleDefinitions = new Dictionary(catalog.ModuleDefinitions, StringComparer.Ordinal), + ModuleRecipes = new Dictionary(catalog.ModuleRecipes, StringComparer.Ordinal), + Recipes = new Dictionary(catalog.Recipes, StringComparer.Ordinal), + OrbitalTimeSeconds = WorldSeed * 97d, + GeneratedAtUtc = DateTimeOffset.UtcNow, + }; + } + + private static List CreateStations( + ScenarioDefinition scenario, + IReadOnlyDictionary systemsById, + IReadOnlyDictionary systemGraphs, + IReadOnlyCollection celestials, + IReadOnlyDictionary moduleDefinitions) + { + var stations = new List(); + 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> BuildPatrolRoutes( + ScenarioDefinition scenario, + IReadOnlyDictionary 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 CreateShips( + ScenarioDefinition scenario, + IReadOnlyDictionary systemsById, + IReadOnlyCollection celestials, + BalanceDefinition balance, + IReadOnlyDictionary shipDefinitions, + IReadOnlyDictionary> patrolRoutes, + StationRuntime? refinery) + { + var ships = new List(); + 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; + } +} diff --git a/apps/backend/Simulation/ScenarioLoader.Seeding.cs b/apps/backend/Simulation/Scenario/WorldSeedingService.cs similarity index 80% rename from apps/backend/Simulation/ScenarioLoader.Seeding.cs rename to apps/backend/Simulation/Scenario/WorldSeedingService.cs index abb6bc5..223631b 100644 --- a/apps/backend/Simulation/ScenarioLoader.Seeding.cs +++ b/apps/backend/Simulation/Scenario/WorldSeedingService.cs @@ -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 CreateFactions( + internal List CreateFactions( IReadOnlyCollection stations, IReadOnlyCollection ships) { var factionIds = stations - .Select((station) => station.FactionId) - .Concat(ships.Select((ship) => ship.FactionId)) - .Where((factionId) => !string.IsNullOrWhiteSpace(factionId)) + .Select(station => station.FactionId) + .Concat(ships.Select(ship => ship.FactionId)) + .Where(factionId => !string.IsNullOrWhiteSpace(factionId)) .Distinct(StringComparer.Ordinal) - .OrderBy((factionId) => factionId, StringComparer.Ordinal) + .OrderBy(factionId => factionId, StringComparer.Ordinal) .ToList(); if (factionIds.Count == 0) @@ -21,33 +23,10 @@ public sealed partial class ScenarioLoader factionIds.Add(DefaultFactionId); } - return factionIds - .Select(CreateFaction) - .ToList(); + return factionIds.Select(CreateFaction).ToList(); } - private static FactionRuntime CreateFaction(string factionId) - { - return factionId switch - { - DefaultFactionId => new FactionRuntime - { - Id = factionId, - Label = "Sol Dominion", - Color = "#7ed4ff", - Credits = MinimumFactionCredits, - }, - _ => new FactionRuntime - { - Id = factionId, - Label = ToFactionLabel(factionId), - Color = "#c7d2e0", - Credits = MinimumFactionCredits, - }, - }; - } - - private static void BootstrapFactionEconomy( + internal void BootstrapFactionEconomy( IReadOnlyCollection factions, IReadOnlyCollection stations) { @@ -56,11 +35,11 @@ public sealed partial class ScenarioLoader faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits); var ownedStations = stations - .Where((station) => station.FactionId == faction.Id) + .Where(station => station.FactionId == faction.Id) .ToList(); 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(); if (refineries.Count > 0) @@ -70,32 +49,52 @@ public sealed partial class ScenarioLoader 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; } } - 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); } } } - private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => - inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + internal void InitializeStationStockpiles(IReadOnlyCollection stations) + { + 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 CreateClaims( + internal StationRuntime? SelectRefineryStation(IReadOnlyCollection 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 CreateClaims( IReadOnlyCollection stations, IReadOnlyCollection celestials, DateTimeOffset nowUtc) { var stationsByCelestialId = stations - .Where((station) => station.CelestialId is not null) - .ToDictionary((station) => station.CelestialId!, StringComparer.Ordinal); + .Where(station => station.CelestialId is not null) + .ToDictionary(station => station.CelestialId!, StringComparer.Ordinal); var claims = new List(); - 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)) { @@ -118,7 +117,7 @@ public sealed partial class ScenarioLoader return claims; } - private static (List ConstructionSites, List MarketOrders) CreateConstructionSites( + internal (List ConstructionSites, List MarketOrders) CreateConstructionSites( IReadOnlyCollection stations, IReadOnlyCollection claims, IReadOnlyDictionary moduleRecipes) @@ -134,7 +133,7 @@ public sealed partial class ScenarioLoader 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)) { continue; @@ -183,43 +182,7 @@ public sealed partial class ScenarioLoader return (sites, orders); } - private static string? GetNextConstructionSiteModule( - StationRuntime station, - IReadOnlyDictionary 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 CreatePolicies(IReadOnlyCollection factions) + internal List CreatePolicies(IReadOnlyCollection factions) { var policies = new List(factions.Count); foreach (var faction in factions) @@ -237,14 +200,14 @@ public sealed partial class ScenarioLoader return policies; } - private static List CreateCommanders( + internal List CreateCommanders( IReadOnlyCollection factions, IReadOnlyCollection stations, IReadOnlyCollection ships) { var commanders = new List(); var factionCommanders = new Dictionary(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) { @@ -330,15 +293,7 @@ public sealed partial class ScenarioLoader return commanders; } - private static string ToFactionLabel(string factionId) - { - return string.Join(" ", - factionId - .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..])); - } - - private static DefaultBehaviorRuntime CreateBehavior( + internal static DefaultBehaviorRuntime CreateBehavior( ShipDefinition definition, string systemId, 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 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() { Kind = kind, diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs deleted file mode 100644 index 863fee8..0000000 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ /dev/null @@ -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>("systems.json"); - var systems = ExpandSystems( - InjectSpecialSystems(authoredSystems), - _worldGeneration.TargetSystemCount); - var scenario = NormalizeScenarioToAvailableSystems( - Read("scenario.json"), - systems.Select((system) => system.Id).ToList()); - var modules = NormalizeModules(Read>("modules.json")); - var ships = Read>("ships.json"); - var items = NormalizeItems(Read>("items.json")); - var balance = Read("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(); - var nodes = new List(); - 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(); - 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(); - 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(string fileName) - { - var path = Path.Combine(_dataRoot, fileName); - var json = File.ReadAllText(path); - return JsonSerializer.Deserialize(json, _jsonOptions) - ?? throw new InvalidOperationException($"Unable to read {fileName}."); - } - - private static ScenarioDefinition NormalizeScenarioToAvailableSystems( - ScenarioDefinition scenario, - IReadOnlyList 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 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 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 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 BuildModuleRecipes(IEnumerable 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 BuildRecipes(IEnumerable items, IEnumerable ships, IReadOnlyCollection modules) - { - var recipes = new List(); - 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 InferRequiredModules(ItemDefinition item, IReadOnlyDictionary 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 NormalizeItems(List 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 NormalizeModules(List 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); - } - -}