diff --git a/apps/backend/.editorconfig b/apps/backend/.editorconfig index 8963e9a..1e8e93b 100644 --- a/apps/backend/.editorconfig +++ b/apps/backend/.editorconfig @@ -2,7 +2,6 @@ root = true [*.{cs,csx}] charset = utf-8 -end_of_line = crlf insert_final_newline = true trim_trailing_whitespace = true indent_style = space @@ -40,7 +39,6 @@ csharp_new_line_before_open_brace = all [*.{csproj,props,targets,sln,slnx}] charset = utf-8 -end_of_line = crlf insert_final_newline = true trim_trailing_whitespace = true indent_style = space @@ -48,7 +46,6 @@ indent_size = 2 [*.{json,jsonc}] charset = utf-8 -end_of_line = crlf insert_final_newline = true trim_trailing_whitespace = true indent_style = space diff --git a/apps/backend/Definitions/WorldDefinitions.cs b/apps/backend/Definitions/WorldDefinitions.cs index 36e5086..4bb66e1 100644 --- a/apps/backend/Definitions/WorldDefinitions.cs +++ b/apps/backend/Definitions/WorldDefinitions.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; using SpaceGame.Api.Shared.Runtime; +using SpaceGame.Api.Universe.Simulation; namespace SpaceGame.Api.Definitions; @@ -40,19 +41,6 @@ public sealed class ItemProductionDefinition public List Effects { get; set; } = []; } -public sealed class BalanceDefinition -{ - public float SimulationSpeedMultiplier { get; set; } = 1f; - public float YPlane { get; set; } - public float ArrivalThreshold { get; set; } - public float MiningRate { get; set; } - public float MiningCycleSeconds { get; set; } - public float TransferRate { get; set; } - public float DockingDuration { get; set; } - public float UndockingDuration { get; set; } - public float UndockDistance { get; set; } -} - public sealed class StarDefinition { public string Kind { get; set; } = "main-sequence"; @@ -374,8 +362,15 @@ public sealed class ShipDefinition public ConstructionDefinition? Construction { get; set; } } +public sealed class GameStartOptionsDefinition +{ + public int Seed { get; set; } = 1; + public WorldGenerationOptions WorldGeneration { get; set; } = new(); +} + public sealed class ScenarioDefinition { + public GameStartOptionsDefinition GameStartOptions { get; set; } = new(); public required List InitialStations { get; set; } public required List ShipFormations { get; set; } public required List PatrolRoutes { get; set; } diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs index 8b17b68..4978f0c 100644 --- a/apps/backend/Program.cs +++ b/apps/backend/Program.cs @@ -1,5 +1,7 @@ using FastEndpoints; using FastEndpoints.Swagger; +using Microsoft.Extensions.Options; +using SpaceGame.Api.Universe.Bootstrap; using SpaceGame.Api.Universe.Simulation; var builder = WebApplication.CreateBuilder(args); @@ -14,14 +16,29 @@ builder.Services.AddCors((options) => .AllowAnyOrigin(); }); }); +builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection("StaticData")) + .Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.") + .ValidateOnStart(); +builder.Services.Configure(builder.Configuration.GetSection("Balance")); builder.Services.Configure(builder.Configuration.GetSection("WorldGeneration")); builder.Services.Configure(builder.Configuration.GetSection("OrbitalSimulation")); -builder.Services.AddFastEndpoints(); -builder.Services.SwaggerDocument(); + +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddFastEndpoints(); +builder.Services.SwaggerDocument(); + var app = builder.Build(); app.UseCors(); diff --git a/apps/backend/Ships/Simulation/ShipAiService.cs b/apps/backend/Ships/Simulation/ShipAiService.cs index 739e7d6..97e6864 100644 --- a/apps/backend/Ships/Simulation/ShipAiService.cs +++ b/apps/backend/Ships/Simulation/ShipAiService.cs @@ -1,10 +1,12 @@ +using Microsoft.Extensions.Options; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService; namespace SpaceGame.Api.Ships.Simulation; -internal sealed class ShipAiService +public sealed class ShipAiService( + IOptions balance) { private const float WarpEngageDistanceKilometers = 250_000f; private const float FrigateDps = 7f; diff --git a/apps/backend/Simulation/Core/SimulationEngine.cs b/apps/backend/Simulation/Core/SimulationEngine.cs index 85ad6be..db24855 100644 --- a/apps/backend/Simulation/Core/SimulationEngine.cs +++ b/apps/backend/Simulation/Core/SimulationEngine.cs @@ -1,4 +1,3 @@ - namespace SpaceGame.Api.Simulation.Core; public sealed class SimulationEngine diff --git a/apps/backend/Universe/Api/UpdateBalanceHandler.cs b/apps/backend/Universe/Api/UpdateBalanceHandler.cs index 85d46dc..085aa2a 100644 --- a/apps/backend/Universe/Api/UpdateBalanceHandler.cs +++ b/apps/backend/Universe/Api/UpdateBalanceHandler.cs @@ -1,10 +1,9 @@ using FastEndpoints; -using SpaceGame.Api.Definitions; using SpaceGame.Api.Universe.Simulation; namespace SpaceGame.Api.Universe.Api; -public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint +public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint { public override void Configure() { @@ -12,7 +11,7 @@ public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint ModuleDefinitions, + IReadOnlyDictionary ShipDefinitions, + IReadOnlyDictionary ItemDefinitions, + IReadOnlyDictionary Recipes, + IReadOnlyDictionary ModuleRecipes, + ProductionGraph ProductionGraph); diff --git a/apps/backend/Universe/Scenario/DataCatalogLoader.cs b/apps/backend/Universe/Bootstrap/StaticDataLoader.cs similarity index 69% rename from apps/backend/Universe/Scenario/DataCatalogLoader.cs rename to apps/backend/Universe/Bootstrap/StaticDataLoader.cs index eaa0ff4..3e733fa 100644 --- a/apps/backend/Universe/Scenario/DataCatalogLoader.cs +++ b/apps/backend/Universe/Bootstrap/StaticDataLoader.cs @@ -1,32 +1,26 @@ using System.Text.Json; +using Microsoft.Extensions.Options; using SpaceGame.Api.Shared.Runtime; -using static SpaceGame.Api.Universe.Scenario.LoaderSupport; -namespace SpaceGame.Api.Universe.Scenario; +namespace SpaceGame.Api.Universe.Bootstrap; -internal sealed class DataCatalogLoader(string dataRoot) +internal sealed class StaticDataLoader(IOptions staticDataOptions) { private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true, }; - internal ScenarioCatalog LoadCatalog() + internal StaticDataCatalog Load() { - var authoredSystems = Read>("systems.json"); - var scenario = Read("scenario.json"); var modules = NormalizeModules(Read>("modules.json")); var ships = Read>("ships.json"); var items = Read>("items.json"); - var balance = Read("balance.json"); var recipes = BuildRecipes(items, ships, modules); var moduleRecipes = BuildModuleRecipes(modules); var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); - return new ScenarioCatalog( - authoredSystems, - scenario, - balance, + return new StaticDataCatalog( modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal), ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), @@ -35,67 +29,9 @@ internal sealed class DataCatalogLoader(string dataRoot) productionGraph); } - 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, - 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(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 T Read(string fileName) { - var path = Path.Combine(dataRoot, fileName); + var path = Path.Combine(staticDataOptions.Value.DataRoot, fileName); var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, _jsonOptions) ?? throw new InvalidOperationException($"Unable to read {fileName}."); @@ -153,11 +89,11 @@ internal sealed class DataCatalogLoader(string dataRoot) Outputs = [ new RecipeOutputDefinition - { - ItemId = item.Id, - Amount = production.Amount, - }, - ], + { + ItemId = item.Id, + Amount = production.Amount, + }, + ], }); } @@ -187,11 +123,11 @@ internal sealed class DataCatalogLoader(string dataRoot) Outputs = [ new RecipeOutputDefinition - { - ItemId = item.Id, - Amount = item.Construction.BatchSize, - }, - ], + { + ItemId = item.Id, + Amount = item.Construction.BatchSize, + }, + ], }); } @@ -270,7 +206,6 @@ internal sealed class DataCatalogLoader(string dataRoot) } module.Type = module.ModuleType.ToDataValue(); - modules[index] = CreateSpecializedModuleDefinition(module); } @@ -314,14 +249,3 @@ internal sealed class DataCatalogLoader(string dataRoot) return module; } } - -internal sealed record ScenarioCatalog( - List AuthoredSystems, - ScenarioDefinition Scenario, - BalanceDefinition Balance, - IReadOnlyDictionary ModuleDefinitions, - IReadOnlyDictionary ShipDefinitions, - IReadOnlyDictionary ItemDefinitions, - IReadOnlyDictionary Recipes, - IReadOnlyDictionary ModuleRecipes, - ProductionGraph ProductionGraph); diff --git a/apps/backend/Universe/Bootstrap/StaticDataOptions.cs b/apps/backend/Universe/Bootstrap/StaticDataOptions.cs new file mode 100644 index 0000000..5dfe9ea --- /dev/null +++ b/apps/backend/Universe/Bootstrap/StaticDataOptions.cs @@ -0,0 +1,6 @@ +namespace SpaceGame.Api.Universe.Bootstrap; + +public sealed class StaticDataOptions +{ + public required string DataRoot { get; init; } +} diff --git a/apps/backend/Universe/Bootstrap/SystemTemplateLoader.cs b/apps/backend/Universe/Bootstrap/SystemTemplateLoader.cs new file mode 100644 index 0000000..0cc97c5 --- /dev/null +++ b/apps/backend/Universe/Bootstrap/SystemTemplateLoader.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; + +namespace SpaceGame.Api.Universe.Bootstrap; + +public sealed class SystemTemplateLoader(IOptions staticDataOptions) +{ + private readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + internal List Load() + { + var path = Path.Combine(staticDataOptions.Value.DataRoot, "systems.json"); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize>(json, _jsonOptions) + ?? throw new InvalidOperationException("Unable to read systems.json."); + } +} diff --git a/apps/backend/Universe/Bootstrap/WorldBootstrapper.cs b/apps/backend/Universe/Bootstrap/WorldBootstrapper.cs new file mode 100644 index 0000000..1a5cf69 --- /dev/null +++ b/apps/backend/Universe/Bootstrap/WorldBootstrapper.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using SpaceGame.Api.Universe.Scenario; +using SpaceGame.Api.Universe.Simulation; + +namespace SpaceGame.Api.Universe.Bootstrap; + +public sealed class WorldBootstrapper +{ + private readonly BalanceOptions _defaultBalance; + private readonly WorldGenerationOptions _defaultWorldGeneration; + private readonly StaticDataCatalog _staticData; + private readonly ScenarioLoader _scenarioLoader; + private readonly SystemTemplateLoader _systemTemplateLoader; + private readonly WorldBuilder _worldBuilder; + + public WorldBootstrapper( + StaticDataCatalog staticData, + ScenarioLoader scenarioLoader, + SystemTemplateLoader systemTemplateLoader, + WorldBuilder worldBuilder, + IOptions defaultBalanceOptions, + IOptions defaultWorldGenerationOptions) + { + _defaultBalance = defaultBalanceOptions.Value; + _defaultWorldGeneration = defaultWorldGenerationOptions.Value; + _staticData = staticData; + _scenarioLoader = scenarioLoader; + _systemTemplateLoader = systemTemplateLoader; + _worldBuilder = worldBuilder; + } + + public SimulationWorld Bootstrap() + { + var scenario = _scenarioLoader.Load(); + var gameStartOptions = scenario?.GameStartOptions ?? new GameStartOptionsDefinition + { + Seed = 1, + WorldGeneration = _defaultWorldGeneration, + }; + + return _worldBuilder.Build( + _staticData, + _defaultBalance, + _systemTemplateLoader.Load(), + gameStartOptions, + scenario); + } +} diff --git a/apps/backend/Universe/Runtime/SimulationWorld.cs b/apps/backend/Universe/Runtime/SimulationWorld.cs index e5be667..a07748c 100644 --- a/apps/backend/Universe/Runtime/SimulationWorld.cs +++ b/apps/backend/Universe/Runtime/SimulationWorld.cs @@ -5,7 +5,6 @@ public sealed class SimulationWorld { public required string Label { get; init; } public required int Seed { get; init; } - public required BalanceDefinition Balance { get; set; } public required List Systems { get; init; } public required List Nodes { get; init; } public required List Celestials { get; init; } diff --git a/apps/backend/Universe/Scenario/LoaderSupport.cs b/apps/backend/Universe/Scenario/LoaderSupport.cs index 96df2d4..d36dbd9 100644 --- a/apps/backend/Universe/Scenario/LoaderSupport.cs +++ b/apps/backend/Universe/Scenario/LoaderSupport.cs @@ -6,7 +6,6 @@ namespace SpaceGame.Api.Universe.Scenario; 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; diff --git a/apps/backend/Universe/Scenario/ScenarioLoader.cs b/apps/backend/Universe/Scenario/ScenarioLoader.cs index 824a05d..31bf7fb 100644 --- a/apps/backend/Universe/Scenario/ScenarioLoader.cs +++ b/apps/backend/Universe/Scenario/ScenarioLoader.cs @@ -1,26 +1,27 @@ +using System.Text.Json; +using Microsoft.Extensions.Options; +using SpaceGame.Api.Universe.Bootstrap; + namespace SpaceGame.Api.Universe.Scenario; -public sealed class ScenarioLoader +public sealed class ScenarioLoader(IOptions staticDataOptions) { - private readonly WorldBuilder _worldBuilder; - - public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null) + private readonly JsonSerializerOptions _jsonOptions = new() { - 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(); + PropertyNameCaseInsensitive = true, + }; - _worldBuilder = new WorldBuilder( - generationOptions, - dataLoader, - generationService, - spatialBuilder, - seedingService); + public ScenarioDefinition? Load() + { + var scenarioPath = Path.Combine(staticDataOptions.Value.DataRoot, "scenario.json"); + if (!File.Exists(scenarioPath)) + { + return null; + } + + var json = File.ReadAllText(scenarioPath); + return JsonSerializer.Deserialize(json, _jsonOptions) + ?? throw new InvalidOperationException("Unable to read scenario.json."); } - - public SimulationWorld Load() => _worldBuilder.Build(); } diff --git a/apps/backend/Universe/Scenario/SpatialBuilder.cs b/apps/backend/Universe/Scenario/SpatialBuilder.cs index 47b44fd..b03d6e2 100644 --- a/apps/backend/Universe/Scenario/SpatialBuilder.cs +++ b/apps/backend/Universe/Scenario/SpatialBuilder.cs @@ -2,9 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; -internal sealed class SpatialBuilder +public sealed class SpatialBuilder { - internal ScenarioSpatialLayout BuildLayout(IReadOnlyList systems, BalanceDefinition balance) + internal ScenarioSpatialLayout BuildLayout(IReadOnlyList systems, BalanceOptions balance) { var systemGraphs = systems.ToDictionary( system => system.Definition.Id, diff --git a/apps/backend/Universe/Scenario/SystemGenerationService.cs b/apps/backend/Universe/Scenario/SystemGenerationService.cs index ee821a6..5ea53fb 100644 --- a/apps/backend/Universe/Scenario/SystemGenerationService.cs +++ b/apps/backend/Universe/Scenario/SystemGenerationService.cs @@ -2,12 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; -internal sealed class SystemGenerationService +public sealed class SystemGenerationService { - private const string SolSystemId = "sol"; - private const string DevelopmentCompanionSystemId = "helios"; - - internal List InjectSpecialSystems(IReadOnlyList authoredSystems) => + internal List PrepareAuthoredSystems(IReadOnlyList authoredSystems) => authoredSystems .Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index)) .ToList(); @@ -71,8 +68,10 @@ internal sealed class SystemGenerationService } } - AddById(SolSystemId); - AddById(DevelopmentCompanionSystemId); + foreach (var preferredSystemId in SystemSelectionPolicy.PreferredSystemIds) + { + AddById(preferredSystemId); + } foreach (var system in systems) { diff --git a/apps/backend/Universe/Scenario/SystemSelectionPolicy.cs b/apps/backend/Universe/Scenario/SystemSelectionPolicy.cs new file mode 100644 index 0000000..8c98bce --- /dev/null +++ b/apps/backend/Universe/Scenario/SystemSelectionPolicy.cs @@ -0,0 +1,23 @@ +namespace SpaceGame.Api.Universe.Scenario; + +internal static class SystemSelectionPolicy +{ + internal static readonly string[] PreferredSystemIds = + [ + "sol", + "helios", + ]; + + internal static string SelectFallbackSystemId(IReadOnlyList availableSystemIds) + { + foreach (var preferredSystemId in PreferredSystemIds) + { + if (availableSystemIds.Contains(preferredSystemId, StringComparer.Ordinal)) + { + return preferredSystemId; + } + } + + return availableSystemIds.FirstOrDefault() ?? string.Empty; + } +} diff --git a/apps/backend/Universe/Scenario/WorldBuilder.cs b/apps/backend/Universe/Scenario/WorldBuilder.cs index ae0e677..361183a 100644 --- a/apps/backend/Universe/Scenario/WorldBuilder.cs +++ b/apps/backend/Universe/Scenario/WorldBuilder.cs @@ -1,23 +1,26 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport; +using SpaceGame.Api.Universe.Bootstrap; +using Microsoft.Extensions.Options; namespace SpaceGame.Api.Universe.Scenario; -internal sealed class WorldBuilder( - WorldGenerationOptions worldGeneration, - DataCatalogLoader dataLoader, - SystemGenerationService generationService, - SpatialBuilder spatialBuilder, - WorldSeedingService seedingService) +public sealed class WorldBuilder( + StaticDataCatalog staticData, + IOptions balance, + SystemGenerationService generationService, + SpatialBuilder spatialBuilder, + WorldSeedingService seedingService) { - internal SimulationWorld Build() + public SimulationWorld Build( + GameStartOptionsDefinition gameStartOptions, + ScenarioDefinition? scenarioDefinition) { - var catalog = dataLoader.LoadCatalog(); var systems = generationService.ExpandSystems( - generationService.InjectSpecialSystems(catalog.AuthoredSystems), - worldGeneration.TargetSystemCount); + generationService.PrepareAuthoredSystems(authoredSystems), + gameStartOptions.WorldGeneration.TargetSystemCount); - var scenario = dataLoader.NormalizeScenarioToAvailableSystems( - catalog.Scenario, + var scenario = NormalizeScenarioToAvailableSystems( + scenarioDefinition, systems.Select(system => system.Id).ToList()); var systemRuntimes = systems @@ -28,22 +31,22 @@ internal sealed class WorldBuilder( }) .ToList(); var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal); - var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, catalog.Balance); + var spatialLayout = spatialBuilder.BuildLayout(systemRuntimes, balance.Value); var stations = CreateStations( scenario, systemsById, spatialLayout.SystemGraphs, spatialLayout.Celestials, - catalog.ModuleDefinitions, - catalog.ItemDefinitions); + staticData.ModuleDefinitions, + staticData.ItemDefinitions); - seedingService.InitializeStationStockpiles(stations, catalog.ModuleDefinitions); + seedingService.InitializeStationStockpiles(stations, staticData.ModuleDefinitions); var refinery = seedingService.SelectRefineryStation(stations, scenario); var patrolRoutes = BuildPatrolRoutes(scenario, systemsById); - var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, catalog.Balance, catalog.ShipDefinitions, patrolRoutes, stations, refinery); + var ships = CreateShips(scenario, systemsById, spatialLayout.Celestials, staticData.ShipDefinitions, patrolRoutes, stations, refinery); - if (worldGeneration.AiControllerFactionCount < int.MaxValue) + if (gameStartOptions.WorldGeneration.AiControllerFactionCount < int.MaxValue) { var aiFactionIds = stations .Select(s => s.FactionId) @@ -51,7 +54,7 @@ internal sealed class WorldBuilder( .Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal)) .Distinct(StringComparer.Ordinal) .OrderBy(id => id, StringComparer.Ordinal) - .Take(worldGeneration.AiControllerFactionCount) + .Take(gameStartOptions.WorldGeneration.AiControllerFactionCount) .ToHashSet(StringComparer.Ordinal); aiFactionIds.Add(DefaultFactionId); stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); @@ -63,15 +66,14 @@ internal sealed class WorldBuilder( var policies = seedingService.CreatePolicies(factions); var commanders = seedingService.CreateCommanders(factions, stations, ships); var nowUtc = DateTimeOffset.UtcNow; - var playerFaction = worldGeneration.GeneratePlayerFaction + 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 = WorldSeed, - Balance = catalog.Balance, + Seed = gameStartOptions.Seed, Systems = systemRuntimes, Celestials = spatialLayout.Celestials, Nodes = spatialLayout.Nodes, @@ -86,13 +88,13 @@ internal sealed class WorldBuilder( ConstructionSites = [], 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), - ProductionGraph = catalog.ProductionGraph, - OrbitalTimeSeconds = WorldSeed * 97d, + ShipDefinitions = new Dictionary(staticData.ShipDefinitions, StringComparer.Ordinal), + ItemDefinitions = new Dictionary(staticData.ItemDefinitions, StringComparer.Ordinal), + ModuleDefinitions = new Dictionary(staticData.ModuleDefinitions, StringComparer.Ordinal), + ModuleRecipes = new Dictionary(staticData.ModuleRecipes, StringComparer.Ordinal), + Recipes = new Dictionary(staticData.Recipes, StringComparer.Ordinal), + ProductionGraph = staticData.ProductionGraph, + OrbitalTimeSeconds = gameStartOptions.Seed * 97d, GeneratedAtUtc = nowUtc, }; @@ -105,6 +107,79 @@ internal sealed class WorldBuilder( return world; } + private static ScenarioDefinition NormalizeScenarioToAvailableSystems( + ScenarioDefinition? scenario, + IReadOnlyList 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(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 CreateStations( ScenarioDefinition scenario, IReadOnlyDictionary systemsById, @@ -248,11 +323,10 @@ internal sealed class WorldBuilder( StringComparer.Ordinal); } - private static List CreateShips( + private List CreateShips( ScenarioDefinition scenario, IReadOnlyDictionary systemsById, IReadOnlyCollection celestials, - BalanceDefinition balance, IReadOnlyDictionary shipDefinitions, IReadOnlyDictionary> patrolRoutes, IReadOnlyCollection stations, @@ -270,7 +344,7 @@ internal sealed class WorldBuilder( for (var index = 0; index < formation.Count; index += 1) { - var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); + 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 diff --git a/apps/backend/Universe/Scenario/WorldSeedingService.cs b/apps/backend/Universe/Scenario/WorldSeedingService.cs index 5623935..6df9265 100644 --- a/apps/backend/Universe/Scenario/WorldSeedingService.cs +++ b/apps/backend/Universe/Scenario/WorldSeedingService.cs @@ -2,7 +2,7 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport; namespace SpaceGame.Api.Universe.Scenario; -internal sealed class WorldSeedingService +public sealed class WorldSeedingService { internal List CreateFactions( IReadOnlyCollection stations, diff --git a/apps/backend/Universe/Simulation/BalanceOptions.cs b/apps/backend/Universe/Simulation/BalanceOptions.cs new file mode 100644 index 0000000..0d6c427 --- /dev/null +++ b/apps/backend/Universe/Simulation/BalanceOptions.cs @@ -0,0 +1,14 @@ +namespace SpaceGame.Api.Universe.Simulation; + +public sealed class BalanceOptions +{ + public float SimulationSpeedMultiplier { get; set; } = 1f; + public float YPlane { get; set; } + public float ArrivalThreshold { get; set; } + public float MiningRate { get; set; } + public float MiningCycleSeconds { get; set; } + public float TransferRate { get; set; } + public float DockingDuration { get; set; } + public float UndockingDuration { get; set; } + public float UndockDistance { get; set; } +} diff --git a/apps/backend/Universe/Simulation/WorldService.cs b/apps/backend/Universe/Simulation/WorldService.cs index 656d2d4..273aef7 100644 --- a/apps/backend/Universe/Simulation/WorldService.cs +++ b/apps/backend/Universe/Simulation/WorldService.cs @@ -1,25 +1,26 @@ using System.Threading.Channels; using Microsoft.Extensions.Options; +using SpaceGame.Api.Universe.Bootstrap; namespace SpaceGame.Api.Universe.Simulation; public sealed class WorldService( - IWebHostEnvironment environment, - IOptions worldGenerationOptions, + WorldBootstrapper worldBootstrapper, + IOptions balance, IOptions orbitalSimulationOptions) { private const int DeltaHistoryLimit = 256; private readonly Lock _sync = new(); private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); - private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value); + private readonly WorldBootstrapper _bootstrapper = worldBootstrapper; private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value); private readonly PlayerFactionService _playerFaction = new(); private readonly Dictionary _subscribers = []; private readonly Queue _history = []; - private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); + private SimulationWorld _world = worldBootstrapper.Bootstrap(); private long _sequence; - private BalanceDefinition? _balanceOverride; + private BalanceOptions? _balanceOverride; public WorldSnapshot GetSnapshot() { @@ -45,32 +46,31 @@ public sealed class WorldService( } } - public BalanceDefinition GetBalance() + public BalanceOptions GetBalance() { lock (_sync) { - var b = _world.Balance; - return new BalanceDefinition + return new BalanceOptions { - SimulationSpeedMultiplier = b.SimulationSpeedMultiplier, - YPlane = b.YPlane, - ArrivalThreshold = b.ArrivalThreshold, - MiningRate = b.MiningRate, - MiningCycleSeconds = b.MiningCycleSeconds, - TransferRate = b.TransferRate, - DockingDuration = b.DockingDuration, - UndockingDuration = b.UndockingDuration, - UndockDistance = b.UndockDistance, + SimulationSpeedMultiplier = balance.Value.SimulationSpeedMultiplier, + YPlane = balance.Value.YPlane, + ArrivalThreshold = balance.Value.ArrivalThreshold, + MiningRate = balance.Value.MiningRate, + MiningCycleSeconds = balance.Value.MiningCycleSeconds, + TransferRate = balance.Value.TransferRate, + DockingDuration = balance.Value.DockingDuration, + UndockingDuration = balance.Value.UndockingDuration, + UndockDistance = balance.Value.UndockDistance, }; } } - public BalanceDefinition UpdateBalance(BalanceDefinition balance) + public BalanceOptions UpdateBalance(BalanceOptions balance) { lock (_sync) { _balanceOverride = SanitizeBalance(balance); - ApplyBalance(_world, _balanceOverride); + ApplyBalance(_balanceOverride); return GetBalance(); } } @@ -285,10 +285,10 @@ public sealed class WorldService( { lock (_sync) { - _world = _loader.Load(); + _world = _bootstrapper.Bootstrap(); if (_balanceOverride is not null) { - ApplyBalance(_world, _balanceOverride); + ApplyBalance(_balanceOverride); } _sequence += 1; _history.Clear(); @@ -323,26 +323,25 @@ public sealed class WorldService( } } - private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) => - world.Balance = new BalanceDefinition - { - SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier, - YPlane = balance.YPlane, - ArrivalThreshold = balance.ArrivalThreshold, - MiningRate = balance.MiningRate, - MiningCycleSeconds = balance.MiningCycleSeconds, - TransferRate = balance.TransferRate, - DockingDuration = balance.DockingDuration, - UndockingDuration = balance.UndockingDuration, - UndockDistance = balance.UndockDistance, - }; + private void ApplyBalance(BalanceOptions value) + { + balance.Value.SimulationSpeedMultiplier = value.SimulationSpeedMultiplier; + balance.Value.YPlane = value.YPlane; + balance.Value.ArrivalThreshold = value.ArrivalThreshold; + balance.Value.MiningRate = value.MiningRate; + balance.Value.MiningCycleSeconds = value.MiningCycleSeconds; + balance.Value.TransferRate = value.TransferRate; + balance.Value.DockingDuration = value.DockingDuration; + balance.Value.UndockingDuration = value.UndockingDuration; + balance.Value.UndockDistance = value.UndockDistance; + } - private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate) + private static BalanceOptions SanitizeBalance(BalanceOptions candidate) { static float finiteOr(float value, float fallback) => float.IsFinite(value) ? value : fallback; - return new BalanceDefinition + return new BalanceOptions { SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)), YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)), diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json index c4824e6..9b0d1f4 100644 --- a/apps/backend/appsettings.Development.json +++ b/apps/backend/appsettings.Development.json @@ -11,6 +11,17 @@ "AiControllerFactionCount": 0, "GeneratePlayerFaction": false }, + "Balance": { + "SimulationSpeedMultiplier": 1.5, + "YPlane": 4, + "ArrivalThreshold": 16, + "MiningRate": 10, + "MiningCycleSeconds": 10, + "TransferRate": 56, + "DockingDuration": 1.2, + "UndockingDuration": 1.2, + "UndockDistance": 42 + }, "OrbitalSimulation": { "SimulatedSecondsPerRealSecond": 0 } diff --git a/apps/backend/appsettings.json b/apps/backend/appsettings.json index 76dbf9c..c4d8fe7 100644 --- a/apps/backend/appsettings.json +++ b/apps/backend/appsettings.json @@ -9,6 +9,17 @@ "TargetSystemCount": 160, "IncludeSolSystem": true }, + "Balance": { + "SimulationSpeedMultiplier": 1.5, + "YPlane": 4, + "ArrivalThreshold": 16, + "MiningRate": 10, + "MiningCycleSeconds": 10, + "TransferRate": 56, + "DockingDuration": 1.2, + "UndockingDuration": 1.2, + "UndockDistance": 42 + }, "OrbitalSimulation": { "SimulatedSecondsPerRealSecond": 0 }, diff --git a/shared/data/balance.json b/shared/data/balance.json deleted file mode 100644 index 8293647..0000000 --- a/shared/data/balance.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "simulationSpeedMultiplier": 1.5, - "yPlane": 4, - "arrivalThreshold": 16, - "miningRate": 10, - "miningCycleSeconds": 10, - "transferRate": 56, - "dockingDuration": 1.2, - "undockingDuration": 1.2, - "undockDistance": 42 -} diff --git a/shared/data/scenario.json b/shared/data/scenario.json index 71d0b80..1225592 100644 --- a/shared/data/scenario.json +++ b/shared/data/scenario.json @@ -1,4 +1,12 @@ { + "gameStartOptions": { + "seed": 1, + "worldGeneration": { + "targetSystemCount": 2, + "aiControllerFactionCount": 0, + "generatePlayerFaction": false + } + }, "initialStations": [ { "label": "Dominion Power Relay",