to rename

This commit is contained in:
2026-03-28 11:32:28 -04:00
parent 04d182e93f
commit 640e147ea8
25 changed files with 367 additions and 224 deletions

View File

@@ -2,7 +2,6 @@ root = true
[*.{cs,csx}] [*.{cs,csx}]
charset = utf-8 charset = utf-8
end_of_line = crlf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
indent_style = space indent_style = space
@@ -40,7 +39,6 @@ csharp_new_line_before_open_brace = all
[*.{csproj,props,targets,sln,slnx}] [*.{csproj,props,targets,sln,slnx}]
charset = utf-8 charset = utf-8
end_of_line = crlf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
indent_style = space indent_style = space
@@ -48,7 +46,6 @@ indent_size = 2
[*.{json,jsonc}] [*.{json,jsonc}]
charset = utf-8 charset = utf-8
end_of_line = crlf
insert_final_newline = true insert_final_newline = true
trim_trailing_whitespace = true trim_trailing_whitespace = true
indent_style = space indent_style = space

View File

@@ -1,6 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using SpaceGame.Api.Shared.Runtime; using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Definitions; namespace SpaceGame.Api.Definitions;
@@ -40,19 +41,6 @@ public sealed class ItemProductionDefinition
public List<ItemEffectDefinition> Effects { get; set; } = []; public List<ItemEffectDefinition> 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 sealed class StarDefinition
{ {
public string Kind { get; set; } = "main-sequence"; public string Kind { get; set; } = "main-sequence";
@@ -374,8 +362,15 @@ public sealed class ShipDefinition
public ConstructionDefinition? Construction { get; set; } 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 sealed class ScenarioDefinition
{ {
public GameStartOptionsDefinition GameStartOptions { get; set; } = new();
public required List<InitialStationDefinition> InitialStations { get; set; } public required List<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; } public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; } public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }

View File

@@ -1,5 +1,7 @@
using FastEndpoints; using FastEndpoints;
using FastEndpoints.Swagger; using FastEndpoints.Swagger;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Simulation; using SpaceGame.Api.Universe.Simulation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -14,14 +16,29 @@ builder.Services.AddCors((options) =>
.AllowAnyOrigin(); .AllowAnyOrigin();
}); });
}); });
builder.Services
.AddOptions<StaticDataOptions>()
.Bind(builder.Configuration.GetSection("StaticData"))
.Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.")
.ValidateOnStart();
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration")); builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation")); builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument(); builder.Services.AddTransient<SystemGenerationService>();
builder.Services.AddTransient<SpatialBuilder>();
builder.Services.AddTransient<WorldSeedingService>();
builder.Services.AddTransient<ScenarioLoader>();
builder.Services.AddTransient<SystemTemplateLoader>();
builder.Services.AddTransient<WorldBuilder>();
builder.Services.AddSingleton<WorldBootstrapper>();
builder.Services.AddSingleton<WorldService>(); builder.Services.AddSingleton<WorldService>();
builder.Services.AddSingleton<TelemetryService>(); builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddHostedService<SimulationHostedService>(); builder.Services.AddHostedService<SimulationHostedService>();
builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument();
var app = builder.Build(); var app = builder.Build();
app.UseCors(); app.UseCors();

View File

@@ -1,10 +1,12 @@
using Microsoft.Extensions.Options;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport; using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService; using static SpaceGame.Api.Stations.Simulation.InfrastructureSimulationService;
using static SpaceGame.Api.Stations.Simulation.StationSimulationService; using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.Simulation; namespace SpaceGame.Api.Ships.Simulation;
internal sealed class ShipAiService public sealed class ShipAiService(
IOptions<BalanceOptions> balance)
{ {
private const float WarpEngageDistanceKilometers = 250_000f; private const float WarpEngageDistanceKilometers = 250_000f;
private const float FrigateDps = 7f; private const float FrigateDps = 7f;

View File

@@ -1,4 +1,3 @@
namespace SpaceGame.Api.Simulation.Core; namespace SpaceGame.Api.Simulation.Core;
public sealed class SimulationEngine public sealed class SimulationEngine

View File

@@ -1,10 +1,9 @@
using FastEndpoints; using FastEndpoints;
using SpaceGame.Api.Definitions;
using SpaceGame.Api.Universe.Simulation; using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Api; namespace SpaceGame.Api.Universe.Api;
public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<BalanceDefinition> public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<BalanceOptions>
{ {
public override void Configure() public override void Configure()
{ {
@@ -12,7 +11,7 @@ public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<B
AllowAnonymous(); AllowAnonymous();
} }
public override Task HandleAsync(BalanceDefinition req, CancellationToken cancellationToken) public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken)
{ {
var applied = worldService.UpdateBalance(req); var applied = worldService.UpdateBalance(req);
return SendOkAsync(applied, cancellationToken); return SendOkAsync(applied, cancellationToken);

View File

@@ -0,0 +1,9 @@
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed record StaticDataCatalog(
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions,
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
ProductionGraph ProductionGraph);

View File

@@ -1,32 +1,26 @@
using System.Text.Json; using System.Text.Json;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Shared.Runtime; 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> staticDataOptions)
{ {
private readonly JsonSerializerOptions _jsonOptions = new() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
}; };
internal ScenarioCatalog LoadCatalog() internal StaticDataCatalog Load()
{ {
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var scenario = Read<ScenarioDefinition>("scenario.json");
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json")); var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json"); var ships = Read<List<ShipDefinition>>("ships.json");
var items = Read<List<ItemDefinition>>("items.json"); var items = Read<List<ItemDefinition>>("items.json");
var balance = Read<BalanceDefinition>("balance.json");
var recipes = BuildRecipes(items, ships, modules); var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules); var moduleRecipes = BuildModuleRecipes(modules);
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules); var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
return new ScenarioCatalog( return new StaticDataCatalog(
authoredSystems,
scenario,
balance,
modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal), modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal), ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
items.ToDictionary(definition => definition.Id, StringComparer.Ordinal), items.ToDictionary(definition => definition.Id, StringComparer.Ordinal),
@@ -35,67 +29,9 @@ internal sealed class DataCatalogLoader(string dataRoot)
productionGraph); productionGraph);
} }
internal ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition scenario,
IReadOnlyList<string> availableSystemIds)
{
if (availableSystemIds.Count == 0)
{
return scenario;
}
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
? "sol"
: availableSystemIds[0];
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
InitialStations = scenario.InitialStations
.Select(station => new InitialStationDefinition
{
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
Objective = station.Objective,
StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex,
LagrangeSide = station.LagrangeSide,
Position = station.Position?.ToArray(),
})
.ToList(),
ShipFormations = scenario.ShipFormations
.Select(formation => new ShipFormationDefinition
{
ShipId = formation.ShipId,
Count = formation.Count,
Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId,
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
})
.ToList(),
PatrolRoutes = scenario.PatrolRoutes
.Select(route => new PatrolRouteDefinition
{
SystemId = ResolveSystemId(route.SystemId),
Points = route.Points.Select(point => point.ToArray()).ToList(),
})
.ToList(),
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
},
};
}
private T Read<T>(string fileName) private T Read<T>(string fileName)
{ {
var path = Path.Combine(dataRoot, fileName); var path = Path.Combine(staticDataOptions.Value.DataRoot, fileName);
var json = File.ReadAllText(path); var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, _jsonOptions) return JsonSerializer.Deserialize<T>(json, _jsonOptions)
?? throw new InvalidOperationException($"Unable to read {fileName}."); ?? throw new InvalidOperationException($"Unable to read {fileName}.");
@@ -153,11 +89,11 @@ internal sealed class DataCatalogLoader(string dataRoot)
Outputs = Outputs =
[ [
new RecipeOutputDefinition new RecipeOutputDefinition
{ {
ItemId = item.Id, ItemId = item.Id,
Amount = production.Amount, Amount = production.Amount,
}, },
], ],
}); });
} }
@@ -187,11 +123,11 @@ internal sealed class DataCatalogLoader(string dataRoot)
Outputs = Outputs =
[ [
new RecipeOutputDefinition new RecipeOutputDefinition
{ {
ItemId = item.Id, ItemId = item.Id,
Amount = item.Construction.BatchSize, Amount = item.Construction.BatchSize,
}, },
], ],
}); });
} }
@@ -270,7 +206,6 @@ internal sealed class DataCatalogLoader(string dataRoot)
} }
module.Type = module.ModuleType.ToDataValue(); module.Type = module.ModuleType.ToDataValue();
modules[index] = CreateSpecializedModuleDefinition(module); modules[index] = CreateSpecializedModuleDefinition(module);
} }
@@ -314,14 +249,3 @@ internal sealed class DataCatalogLoader(string dataRoot)
return module; return module;
} }
} }
internal sealed record ScenarioCatalog(
List<SolarSystemDefinition> AuthoredSystems,
ScenarioDefinition Scenario,
BalanceDefinition Balance,
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions,
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions,
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions,
IReadOnlyDictionary<string, RecipeDefinition> Recipes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes,
ProductionGraph ProductionGraph);

View File

@@ -0,0 +1,6 @@
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class StaticDataOptions
{
public required string DataRoot { get; init; }
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class SystemTemplateLoader(IOptions<StaticDataOptions> staticDataOptions)
{
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
internal List<SolarSystemDefinition> Load()
{
var path = Path.Combine(staticDataOptions.Value.DataRoot, "systems.json");
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<List<SolarSystemDefinition>>(json, _jsonOptions)
?? throw new InvalidOperationException("Unable to read systems.json.");
}
}

View File

@@ -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<BalanceOptions> defaultBalanceOptions,
IOptions<WorldGenerationOptions> 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);
}
}

View File

@@ -5,7 +5,6 @@ public sealed class SimulationWorld
{ {
public required string Label { get; init; } public required string Label { get; init; }
public required int Seed { get; init; } public required int Seed { get; init; }
public required BalanceDefinition Balance { get; set; }
public required List<SystemRuntime> Systems { get; init; } public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; } public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<CelestialRuntime> Celestials { get; init; } public required List<CelestialRuntime> Celestials { get; init; }

View File

@@ -6,7 +6,6 @@ namespace SpaceGame.Api.Universe.Scenario;
internal static class LoaderSupport internal static class LoaderSupport
{ {
internal const string DefaultFactionId = "sol-dominion"; internal const string DefaultFactionId = "sol-dominion";
internal const int WorldSeed = 1;
internal const float MinimumFactionCredits = 0f; internal const float MinimumFactionCredits = 0f;
internal const float MinimumRefineryOre = 0f; internal const float MinimumRefineryOre = 0f;
internal const float MinimumRefineryStock = 0f; internal const float MinimumRefineryStock = 0f;

View File

@@ -1,26 +1,27 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Universe.Scenario; namespace SpaceGame.Api.Universe.Scenario;
public sealed class ScenarioLoader public sealed class ScenarioLoader(IOptions<StaticDataOptions> staticDataOptions)
{ {
private readonly WorldBuilder _worldBuilder; private readonly JsonSerializerOptions _jsonOptions = new()
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{ {
var generationOptions = worldGeneration ?? new WorldGenerationOptions(); PropertyNameCaseInsensitive = true,
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( public ScenarioDefinition? Load()
generationOptions, {
dataLoader, var scenarioPath = Path.Combine(staticDataOptions.Value.DataRoot, "scenario.json");
generationService, if (!File.Exists(scenarioPath))
spatialBuilder, {
seedingService); return null;
}
var json = File.ReadAllText(scenarioPath);
return JsonSerializer.Deserialize<ScenarioDefinition>(json, _jsonOptions)
?? throw new InvalidOperationException("Unable to read scenario.json.");
} }
public SimulationWorld Load() => _worldBuilder.Build();
} }

View File

@@ -2,9 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario; namespace SpaceGame.Api.Universe.Scenario;
internal sealed class SpatialBuilder public sealed class SpatialBuilder
{ {
internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceDefinition balance) internal ScenarioSpatialLayout BuildLayout(IReadOnlyList<SystemRuntime> systems, BalanceOptions balance)
{ {
var systemGraphs = systems.ToDictionary( var systemGraphs = systems.ToDictionary(
system => system.Definition.Id, system => system.Definition.Id,

View File

@@ -2,12 +2,9 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario; namespace SpaceGame.Api.Universe.Scenario;
internal sealed class SystemGenerationService public sealed class SystemGenerationService
{ {
private const string SolSystemId = "sol"; internal List<SolarSystemDefinition> PrepareAuthoredSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
private const string DevelopmentCompanionSystemId = "helios";
internal List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) =>
authoredSystems authoredSystems
.Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index)) .Select((system, index) => EnsureStrategicResourceCoverage(CloneSystemDefinition(system), index))
.ToList(); .ToList();
@@ -71,8 +68,10 @@ internal sealed class SystemGenerationService
} }
} }
AddById(SolSystemId); foreach (var preferredSystemId in SystemSelectionPolicy.PreferredSystemIds)
AddById(DevelopmentCompanionSystemId); {
AddById(preferredSystemId);
}
foreach (var system in systems) foreach (var system in systems)
{ {

View File

@@ -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<string> availableSystemIds)
{
foreach (var preferredSystemId in PreferredSystemIds)
{
if (availableSystemIds.Contains(preferredSystemId, StringComparer.Ordinal))
{
return preferredSystemId;
}
}
return availableSystemIds.FirstOrDefault() ?? string.Empty;
}
}

View File

@@ -1,23 +1,26 @@
using static SpaceGame.Api.Universe.Scenario.LoaderSupport; using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
using SpaceGame.Api.Universe.Bootstrap;
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Scenario; namespace SpaceGame.Api.Universe.Scenario;
internal sealed class WorldBuilder( public sealed class WorldBuilder(
WorldGenerationOptions worldGeneration, StaticDataCatalog staticData,
DataCatalogLoader dataLoader, IOptions<BalanceOptions> balance,
SystemGenerationService generationService, SystemGenerationService generationService,
SpatialBuilder spatialBuilder, SpatialBuilder spatialBuilder,
WorldSeedingService seedingService) WorldSeedingService seedingService)
{ {
internal SimulationWorld Build() public SimulationWorld Build(
GameStartOptionsDefinition gameStartOptions,
ScenarioDefinition? scenarioDefinition)
{ {
var catalog = dataLoader.LoadCatalog();
var systems = generationService.ExpandSystems( var systems = generationService.ExpandSystems(
generationService.InjectSpecialSystems(catalog.AuthoredSystems), generationService.PrepareAuthoredSystems(authoredSystems),
worldGeneration.TargetSystemCount); gameStartOptions.WorldGeneration.TargetSystemCount);
var scenario = dataLoader.NormalizeScenarioToAvailableSystems( var scenario = NormalizeScenarioToAvailableSystems(
catalog.Scenario, scenarioDefinition,
systems.Select(system => system.Id).ToList()); systems.Select(system => system.Id).ToList());
var systemRuntimes = systems var systemRuntimes = systems
@@ -28,22 +31,22 @@ internal sealed class WorldBuilder(
}) })
.ToList(); .ToList();
var systemsById = systemRuntimes.ToDictionary(system => system.Definition.Id, StringComparer.Ordinal); 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( var stations = CreateStations(
scenario, scenario,
systemsById, systemsById,
spatialLayout.SystemGraphs, spatialLayout.SystemGraphs,
spatialLayout.Celestials, spatialLayout.Celestials,
catalog.ModuleDefinitions, staticData.ModuleDefinitions,
catalog.ItemDefinitions); staticData.ItemDefinitions);
seedingService.InitializeStationStockpiles(stations, catalog.ModuleDefinitions); seedingService.InitializeStationStockpiles(stations, staticData.ModuleDefinitions);
var refinery = seedingService.SelectRefineryStation(stations, scenario); var refinery = seedingService.SelectRefineryStation(stations, scenario);
var patrolRoutes = BuildPatrolRoutes(scenario, systemsById); 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 var aiFactionIds = stations
.Select(s => s.FactionId) .Select(s => s.FactionId)
@@ -51,7 +54,7 @@ internal sealed class WorldBuilder(
.Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal)) .Where(id => !string.IsNullOrWhiteSpace(id) && !string.Equals(id, DefaultFactionId, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.OrderBy(id => id, StringComparer.Ordinal) .OrderBy(id => id, StringComparer.Ordinal)
.Take(worldGeneration.AiControllerFactionCount) .Take(gameStartOptions.WorldGeneration.AiControllerFactionCount)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
aiFactionIds.Add(DefaultFactionId); aiFactionIds.Add(DefaultFactionId);
stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList(); stations = stations.Where(s => aiFactionIds.Contains(s.FactionId)).ToList();
@@ -63,15 +66,14 @@ internal sealed class WorldBuilder(
var policies = seedingService.CreatePolicies(factions); var policies = seedingService.CreatePolicies(factions);
var commanders = seedingService.CreateCommanders(factions, stations, ships); var commanders = seedingService.CreateCommanders(factions, stations, ships);
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
var playerFaction = worldGeneration.GeneratePlayerFaction var playerFaction = gameStartOptions.WorldGeneration.GeneratePlayerFaction
? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc) ? seedingService.CreatePlayerFaction(factions, stations, ships, commanders, policies, nowUtc)
: null; : null;
var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc); var claims = seedingService.CreateClaims(stations, spatialLayout.Celestials, nowUtc);
var world = new SimulationWorld var world = new SimulationWorld
{ {
Label = "Split Viewer / Simulation World", Label = "Split Viewer / Simulation World",
Seed = WorldSeed, Seed = gameStartOptions.Seed,
Balance = catalog.Balance,
Systems = systemRuntimes, Systems = systemRuntimes,
Celestials = spatialLayout.Celestials, Celestials = spatialLayout.Celestials,
Nodes = spatialLayout.Nodes, Nodes = spatialLayout.Nodes,
@@ -86,13 +88,13 @@ internal sealed class WorldBuilder(
ConstructionSites = [], ConstructionSites = [],
MarketOrders = [], MarketOrders = [],
Policies = policies, Policies = policies,
ShipDefinitions = new Dictionary<string, ShipDefinition>(catalog.ShipDefinitions, StringComparer.Ordinal), ShipDefinitions = new Dictionary<string, ShipDefinition>(staticData.ShipDefinitions, StringComparer.Ordinal),
ItemDefinitions = new Dictionary<string, ItemDefinition>(catalog.ItemDefinitions, StringComparer.Ordinal), ItemDefinitions = new Dictionary<string, ItemDefinition>(staticData.ItemDefinitions, StringComparer.Ordinal),
ModuleDefinitions = new Dictionary<string, ModuleDefinition>(catalog.ModuleDefinitions, StringComparer.Ordinal), ModuleDefinitions = new Dictionary<string, ModuleDefinition>(staticData.ModuleDefinitions, StringComparer.Ordinal),
ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(catalog.ModuleRecipes, StringComparer.Ordinal), ModuleRecipes = new Dictionary<string, ModuleRecipeDefinition>(staticData.ModuleRecipes, StringComparer.Ordinal),
Recipes = new Dictionary<string, RecipeDefinition>(catalog.Recipes, StringComparer.Ordinal), Recipes = new Dictionary<string, RecipeDefinition>(staticData.Recipes, StringComparer.Ordinal),
ProductionGraph = catalog.ProductionGraph, ProductionGraph = staticData.ProductionGraph,
OrbitalTimeSeconds = WorldSeed * 97d, OrbitalTimeSeconds = gameStartOptions.Seed * 97d,
GeneratedAtUtc = nowUtc, GeneratedAtUtc = nowUtc,
}; };
@@ -105,6 +107,79 @@ internal sealed class WorldBuilder(
return world; return world;
} }
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition? scenario,
IReadOnlyList<string> availableSystemIds)
{
var fallbackSystemId = SystemSelectionPolicy.SelectFallbackSystemId(availableSystemIds);
if (scenario is null)
{
return new ScenarioDefinition
{
GameStartOptions = new GameStartOptionsDefinition(),
InitialStations = [],
ShipFormations = [],
PatrolRoutes = [],
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = fallbackSystemId,
RefinerySystemId = fallbackSystemId,
},
};
}
if (availableSystemIds.Count == 0)
{
return scenario;
}
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
GameStartOptions = scenario.GameStartOptions,
InitialStations = scenario.InitialStations
.Select(station => new InitialStationDefinition
{
SystemId = ResolveSystemId(station.SystemId),
Label = station.Label,
Color = station.Color,
Objective = station.Objective,
StartingModules = station.StartingModules.ToList(),
FactionId = station.FactionId,
PlanetIndex = station.PlanetIndex,
LagrangeSide = station.LagrangeSide,
Position = station.Position?.ToArray(),
})
.ToList(),
ShipFormations = scenario.ShipFormations
.Select(formation => new ShipFormationDefinition
{
ShipId = formation.ShipId,
Count = formation.Count,
Center = formation.Center.ToArray(),
SystemId = ResolveSystemId(formation.SystemId),
FactionId = formation.FactionId,
StartingInventory = new Dictionary<string, float>(formation.StartingInventory, StringComparer.Ordinal),
})
.ToList(),
PatrolRoutes = scenario.PatrolRoutes
.Select(route => new PatrolRouteDefinition
{
SystemId = ResolveSystemId(route.SystemId),
Points = route.Points.Select(point => point.ToArray()).ToList(),
})
.ToList(),
MiningDefaults = new MiningDefaultsDefinition
{
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
},
};
}
private static List<StationRuntime> CreateStations( private static List<StationRuntime> CreateStations(
ScenarioDefinition scenario, ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById, IReadOnlyDictionary<string, SystemRuntime> systemsById,
@@ -248,11 +323,10 @@ internal sealed class WorldBuilder(
StringComparer.Ordinal); StringComparer.Ordinal);
} }
private static List<ShipRuntime> CreateShips( private List<ShipRuntime> CreateShips(
ScenarioDefinition scenario, ScenarioDefinition scenario,
IReadOnlyDictionary<string, SystemRuntime> systemsById, IReadOnlyDictionary<string, SystemRuntime> systemsById,
IReadOnlyCollection<CelestialRuntime> celestials, IReadOnlyCollection<CelestialRuntime> celestials,
BalanceDefinition balance,
IReadOnlyDictionary<string, ShipDefinition> shipDefinitions, IReadOnlyDictionary<string, ShipDefinition> shipDefinitions,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes, IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,
@@ -270,7 +344,7 @@ internal sealed class WorldBuilder(
for (var index = 0; index < formation.Count; index += 1) 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); var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset);
ships.Add(new ShipRuntime ships.Add(new ShipRuntime

View File

@@ -2,7 +2,7 @@ using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario; namespace SpaceGame.Api.Universe.Scenario;
internal sealed class WorldSeedingService public sealed class WorldSeedingService
{ {
internal List<FactionRuntime> CreateFactions( internal List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations, IReadOnlyCollection<StationRuntime> stations,

View File

@@ -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; }
}

View File

@@ -1,25 +1,26 @@
using System.Threading.Channels; using System.Threading.Channels;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
namespace SpaceGame.Api.Universe.Simulation; namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService( public sealed class WorldService(
IWebHostEnvironment environment, WorldBootstrapper worldBootstrapper,
IOptions<WorldGenerationOptions> worldGenerationOptions, IOptions<BalanceOptions> balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions) IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{ {
private const int DeltaHistoryLimit = 256; private const int DeltaHistoryLimit = 256;
private readonly Lock _sync = new(); private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond); 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 SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly PlayerFactionService _playerFaction = new(); private readonly PlayerFactionService _playerFaction = new();
private readonly Dictionary<Guid, SubscriptionState> _subscribers = []; private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = []; private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load(); private SimulationWorld _world = worldBootstrapper.Bootstrap();
private long _sequence; private long _sequence;
private BalanceDefinition? _balanceOverride; private BalanceOptions? _balanceOverride;
public WorldSnapshot GetSnapshot() public WorldSnapshot GetSnapshot()
{ {
@@ -45,32 +46,31 @@ public sealed class WorldService(
} }
} }
public BalanceDefinition GetBalance() public BalanceOptions GetBalance()
{ {
lock (_sync) lock (_sync)
{ {
var b = _world.Balance; return new BalanceOptions
return new BalanceDefinition
{ {
SimulationSpeedMultiplier = b.SimulationSpeedMultiplier, SimulationSpeedMultiplier = balance.Value.SimulationSpeedMultiplier,
YPlane = b.YPlane, YPlane = balance.Value.YPlane,
ArrivalThreshold = b.ArrivalThreshold, ArrivalThreshold = balance.Value.ArrivalThreshold,
MiningRate = b.MiningRate, MiningRate = balance.Value.MiningRate,
MiningCycleSeconds = b.MiningCycleSeconds, MiningCycleSeconds = balance.Value.MiningCycleSeconds,
TransferRate = b.TransferRate, TransferRate = balance.Value.TransferRate,
DockingDuration = b.DockingDuration, DockingDuration = balance.Value.DockingDuration,
UndockingDuration = b.UndockingDuration, UndockingDuration = balance.Value.UndockingDuration,
UndockDistance = b.UndockDistance, UndockDistance = balance.Value.UndockDistance,
}; };
} }
} }
public BalanceDefinition UpdateBalance(BalanceDefinition balance) public BalanceOptions UpdateBalance(BalanceOptions balance)
{ {
lock (_sync) lock (_sync)
{ {
_balanceOverride = SanitizeBalance(balance); _balanceOverride = SanitizeBalance(balance);
ApplyBalance(_world, _balanceOverride); ApplyBalance(_balanceOverride);
return GetBalance(); return GetBalance();
} }
} }
@@ -285,10 +285,10 @@ public sealed class WorldService(
{ {
lock (_sync) lock (_sync)
{ {
_world = _loader.Load(); _world = _bootstrapper.Bootstrap();
if (_balanceOverride is not null) if (_balanceOverride is not null)
{ {
ApplyBalance(_world, _balanceOverride); ApplyBalance(_balanceOverride);
} }
_sequence += 1; _sequence += 1;
_history.Clear(); _history.Clear();
@@ -323,26 +323,25 @@ public sealed class WorldService(
} }
} }
private static void ApplyBalance(SimulationWorld world, BalanceDefinition balance) => private void ApplyBalance(BalanceOptions value)
world.Balance = new BalanceDefinition {
{ balance.Value.SimulationSpeedMultiplier = value.SimulationSpeedMultiplier;
SimulationSpeedMultiplier = balance.SimulationSpeedMultiplier, balance.Value.YPlane = value.YPlane;
YPlane = balance.YPlane, balance.Value.ArrivalThreshold = value.ArrivalThreshold;
ArrivalThreshold = balance.ArrivalThreshold, balance.Value.MiningRate = value.MiningRate;
MiningRate = balance.MiningRate, balance.Value.MiningCycleSeconds = value.MiningCycleSeconds;
MiningCycleSeconds = balance.MiningCycleSeconds, balance.Value.TransferRate = value.TransferRate;
TransferRate = balance.TransferRate, balance.Value.DockingDuration = value.DockingDuration;
DockingDuration = balance.DockingDuration, balance.Value.UndockingDuration = value.UndockingDuration;
UndockingDuration = balance.UndockingDuration, balance.Value.UndockDistance = value.UndockDistance;
UndockDistance = balance.UndockDistance, }
};
private static BalanceDefinition SanitizeBalance(BalanceDefinition candidate) private static BalanceOptions SanitizeBalance(BalanceOptions candidate)
{ {
static float finiteOr(float value, float fallback) => static float finiteOr(float value, float fallback) =>
float.IsFinite(value) ? value : fallback; float.IsFinite(value) ? value : fallback;
return new BalanceDefinition return new BalanceOptions
{ {
SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)), SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)),
YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)), YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)),

View File

@@ -11,6 +11,17 @@
"AiControllerFactionCount": 0, "AiControllerFactionCount": 0,
"GeneratePlayerFaction": false "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": { "OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0 "SimulatedSecondsPerRealSecond": 0
} }

View File

@@ -9,6 +9,17 @@
"TargetSystemCount": 160, "TargetSystemCount": 160,
"IncludeSolSystem": true "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": { "OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0 "SimulatedSecondsPerRealSecond": 0
}, },

View File

@@ -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
}

View File

@@ -1,4 +1,12 @@
{ {
"gameStartOptions": {
"seed": 1,
"worldGeneration": {
"targetSystemCount": 2,
"aiControllerFactionCount": 0,
"generatePlayerFaction": false
}
},
"initialStations": [ "initialStations": [
{ {
"label": "Dominion Power Relay", "label": "Dominion Power Relay",