Refactor world bootstrap and allow empty startup worlds

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

View File

@@ -75,6 +75,39 @@ public sealed class SolarSystemDefinition
public required List<PlanetDefinition> Planets { get; set; }
}
public sealed class RaceDefinition
{
public required string Id { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
[JsonIgnore]
public string Label => Name;
}
public sealed class FactionDefinition
{
public required string Id { get; set; }
public int Version { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public string? Race { get; set; }
public List<FactionLicenseDefinition> Licenses { get; set; } = [];
[JsonIgnore]
public string Label => Name;
[JsonIgnore]
public string? RaceId => Race;
}
public sealed class FactionLicenseDefinition
{
public required string Type { get; set; }
public string Name { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public float Price { get; set; }
}
public sealed class AsteroidFieldDefinition
{
public int DecorationCount { get; set; }
@@ -338,43 +371,174 @@ public sealed class PlanetDefinition
public sealed class ShipDefinition
{
public required string Id { get; set; }
public required string Label { get; set; }
public required string Kind { get; set; }
public required string Class { get; set; }
public float Speed { get; set; }
public float WarpSpeed { get; set; }
public float FtlSpeed { get; set; }
public float SpoolTime { get; set; }
public float CargoCapacity { get; set; }
public int Version { get; set; }
public required string Name { get; set; }
public string Description { get; set; } = string.Empty;
public string Size { get; set; } = string.Empty;
public float ExplosionDamage { get; set; }
public float Hull { get; set; }
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
public int People { get; set; }
public string Purpose { get; set; } = string.Empty;
public string Thruster { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public float Mass { get; set; }
public ShipInertiaDefinition? Inertia { get; set; }
public ShipDragDefinition? Drag { get; set; }
public List<ShipMountDefinition> Engines { get; set; } = [];
public List<ShipMountDefinition> Shields { get; set; } = [];
public List<ShipMountDefinition> Weapons { get; set; } = [];
public List<ShipMountDefinition> Turrets { get; set; } = [];
public List<ShipCargoDefinition> Cargo { get; set; } = [];
public List<ModuleDockDefinition> Docks { get; set; } = [];
public List<string> Owners { get; set; } = [];
public ItemPriceDefinition? Price { get; set; }
public List<ItemProductionDefinition> Production { get; set; } = [];
[JsonIgnore]
public StorageKind? CargoKind { get; set; }
[JsonPropertyName("cargoKind")]
public string? SerializedCargoKind
public string Label => Name;
[JsonIgnore]
public string Kind => InferKind(Purpose);
[JsonIgnore]
public string Class => Type;
[JsonIgnore]
public float Speed => InferLocalSpeed(Size);
[JsonIgnore]
public float WarpSpeed => InferWarpSpeed(Size);
[JsonIgnore]
public float FtlSpeed => InferFtlSpeed(Size);
[JsonIgnore]
public float SpoolTime => InferSpoolTime(Size);
[JsonIgnore]
public float CargoCapacity => Cargo.Sum(entry => entry.Max);
[JsonIgnore]
public StorageKind? CargoKind => Cargo
.SelectMany(entry => entry.Types)
.Select(type => type.ToNullableStorageKind())
.FirstOrDefault(kind => kind is not null);
[JsonIgnore]
public float MaxHealth => Hull;
[JsonIgnore]
public IReadOnlyList<string> Capabilities => InferCapabilities(Purpose, Type, Cargo, Turrets);
private static string InferKind(string purpose) =>
purpose switch
{
"build" => "construction",
"trade" => "transport",
"mine" => "mining",
"fight" => "military",
"auxiliary" => "military",
_ => purpose,
};
private static List<string> InferCapabilities(
string purpose,
string type,
IReadOnlyCollection<ShipCargoDefinition> cargo,
IReadOnlyCollection<ShipMountDefinition> turrets)
{
get => CargoKind?.ToDataValue();
set => CargoKind = value.ToNullableStorageKind();
var capabilities = new List<string> { "warp", "ftl" };
if (string.Equals(purpose, "mine", StringComparison.Ordinal)
|| type.Contains("miner", StringComparison.Ordinal)
|| turrets.Any(turret => turret.Types.Contains("mining", StringComparer.Ordinal)))
{
capabilities.Add("mining");
}
if (cargo.Any(entry => entry.Types.Contains("container", StringComparer.Ordinal)
|| entry.Types.Contains("solid", StringComparer.Ordinal)
|| entry.Types.Contains("liquid", StringComparer.Ordinal)))
{
capabilities.Add("cargo");
}
return capabilities;
}
public required string Color { get; set; }
public required string HullColor { get; set; }
public float Size { get; set; }
public float MaxHealth { get; set; }
public List<string> Capabilities { get; set; } = [];
public ConstructionDefinition? Construction { get; set; }
private static float InferWarpSpeed(string size) =>
size switch
{
"extrasmall" => 4.8f,
"small" => 4.2f,
"medium" => 3.4f,
"large" => 2.4f,
"extralarge" => 1.8f,
_ => 3f,
};
private static float InferLocalSpeed(string size) =>
size switch
{
"extrasmall" => 420f,
"small" => 320f,
"medium" => 230f,
"large" => 150f,
"extralarge" => 110f,
_ => 200f,
};
private static float InferFtlSpeed(string size) =>
size switch
{
"extrasmall" => 1f,
"small" => 0.85f,
"medium" => 0.7f,
"large" => 0.55f,
"extralarge" => 0.45f,
_ => 0.6f,
};
private static float InferSpoolTime(string size) =>
size switch
{
"extrasmall" => 0.8f,
"small" => 1f,
"medium" => 1.4f,
"large" => 2f,
"extralarge" => 2.6f,
_ => 1.5f,
};
}
public sealed class GameStartOptionsDefinition
public sealed class ShipInertiaDefinition
{
public int Seed { get; set; } = 1;
public WorldGenerationOptions WorldGeneration { get; set; } = new();
public float Pitch { get; set; }
public float Yaw { get; set; }
public float Roll { get; set; }
}
public sealed class ShipDragDefinition
{
public float Forward { get; set; }
public float Reverse { get; set; }
public float Horizontal { get; set; }
public float Vertical { get; set; }
public float Pitch { get; set; }
public float Yaw { get; set; }
public float Roll { get; set; }
}
public sealed class ShipMountDefinition
{
public string? Group { get; set; }
public required string Size { get; set; }
public bool Hittable { get; set; }
public List<string> Types { get; set; } = [];
}
public sealed class ShipCargoDefinition
{
public float Max { get; set; }
public List<string> Types { get; set; } = [];
}
public sealed class ScenarioDefinition
{
public GameStartOptionsDefinition GameStartOptions { get; set; } = new();
public required WorldGenerationOptions WorldGeneration { get; set; }
public required List<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
public required MiningDefaultsDefinition MiningDefaults { get; set; }
}
public sealed class InitialStationDefinition
@@ -405,9 +569,3 @@ public sealed class PatrolRouteDefinition
public required string SystemId { get; set; }
public required List<float[]> Points { get; set; }
}
public sealed class MiningDefaultsDefinition
{
public required string NodeSystemId { get; set; }
public required string RefinerySystemId { get; set; }
}

View File

@@ -16,8 +16,8 @@ internal sealed class PlayerFactionService
return world.PlayerFaction;
}
var sovereignFaction = world.Factions.FirstOrDefault(faction => string.Equals(faction.Id, LoaderSupport.DefaultFactionId, StringComparison.Ordinal))
?? world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).First();
var sovereignFaction = world.Factions.OrderBy(faction => faction.Id, StringComparer.Ordinal).FirstOrDefault()
?? throw new InvalidOperationException("Cannot create a player faction domain without any factions in the world.");
world.PlayerFaction = new PlayerFactionRuntime
{
@@ -35,6 +35,11 @@ internal sealed class PlayerFactionService
internal void Update(SimulationWorld world, float _deltaSeconds, ICollection<SimulationEventRecord> events)
{
if (world.PlayerFaction is null && world.Factions.Count == 0)
{
return;
}
var player = EnsureDomain(world);
EnsureBaseStructures(world, player);
SyncRegistry(world, player);

View File

@@ -1,10 +1,11 @@
using FastEndpoints;
using FastEndpoints.Swagger;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Scenario;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Simulation;
var builder = WebApplication.CreateBuilder(args);
const string StartupScenarioPath = "scenarios/empty.json";
builder.Services.AddCors((options) =>
{
@@ -17,21 +18,48 @@ builder.Services.AddCors((options) =>
});
});
builder.Services
.AddOptions<StaticDataOptions>()
.Bind(builder.Configuration.GetSection("StaticData"))
.Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.")
.ValidateOnStart();
.AddOptions<StaticDataOptions>()
.Bind(builder.Configuration.GetSection("StaticData"))
.Validate(options => !string.IsNullOrWhiteSpace(options.DataRoot), "StaticData:DataRoot must be configured.")
.PostConfigure(options =>
{
if (Path.IsPathRooted(options.DataRoot))
{
options.DataRoot = Path.GetFullPath(options.DataRoot);
return;
}
var candidatePaths = new[]
{
Path.GetFullPath(options.DataRoot),
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, options.DataRoot)),
Path.GetFullPath(Path.Combine(builder.Environment.ContentRootPath, "..", "..", options.DataRoot)),
};
var resolvedPath = candidatePaths.FirstOrDefault(Directory.Exists);
if (resolvedPath is null)
{
throw new InvalidOperationException($"StaticData:DataRoot '{options.DataRoot}' could not be resolved to an existing directory.");
}
options.DataRoot = resolvedPath;
})
.ValidateOnStart();
builder.Services.Configure<BalanceOptions>(builder.Configuration.GetSection("Balance"));
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddSingleton<IBalanceService, BalanceService>();
builder.Services.AddTransient<SystemGenerationService>();
builder.Services.AddTransient<SpatialBuilder>();
builder.Services.AddTransient<WorldSeedingService>();
builder.Services.AddTransient<ScenarioValidationService>();
builder.Services.AddTransient<ScenarioContentBuilder>();
builder.Services.AddTransient<ScenarioLoader>();
builder.Services.AddTransient<SystemTemplateLoader>();
builder.Services.AddTransient<WorldTopologyBuilder>();
builder.Services.AddTransient<WorldRuntimeAssembler>();
builder.Services.AddTransient<WorldBuilder>();
builder.Services.AddSingleton<WorldBootstrapper>();
builder.Services.AddSingleton<IStaticDataProvider, StaticDataProvider>();
builder.Services.AddSingleton<WorldService>();
builder.Services.AddSingleton<TelemetryService>();
builder.Services.AddHostedService<SimulationHostedService>();
@@ -40,6 +68,7 @@ builder.Services.AddFastEndpoints();
builder.Services.SwaggerDocument();
var app = builder.Build();
app.Services.GetRequiredService<WorldService>().LoadFromScenario(StartupScenarioPath);
app.UseCors();
app.UseFastEndpoints();

View File

@@ -1,4 +1,3 @@
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;
@@ -6,7 +5,7 @@ using static SpaceGame.Api.Stations.Simulation.StationSimulationService;
namespace SpaceGame.Api.Ships.Simulation;
public sealed class ShipAiService(
IOptions<BalanceOptions> balance)
IBalanceService balance)
{
private const float WarpEngageDistanceKilometers = 250_000f;
private const float FrigateDps = 7f;
@@ -249,7 +248,7 @@ public sealed class ShipAiService(
plan.Steps.Add(CreateStep("step-flee-travel", "travel", "Travel to safe station",
[
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(world.Balance.ArrivalThreshold, safeStation.Radius + 12f), 0f)
CreateSubTask("sub-flee-travel", ShipTaskKinds.Travel, $"Travel to {safeStation.Label}", safeStation.SystemId, safeStation.Position, safeStation.Id, MathF.Max(balance.ArrivalThreshold, safeStation.Radius + 12f), 0f)
]));
plan.Steps.Add(CreateStep("step-flee-dock", "dock", "Dock at safe station",
[
@@ -353,7 +352,7 @@ public sealed class ShipAiService(
[
CreateStep("step-dock-travel", "travel", $"Travel to {station.Label}",
[
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(world.Balance.ArrivalThreshold, station.Radius + 12f), 0f)
CreateSubTask("sub-dock-travel", ShipTaskKinds.Travel, $"Travel to {station.Label}", station.SystemId, station.Position, station.Id, MathF.Max(balance.ArrivalThreshold, station.Radius + 12f), 0f)
]),
CreateStep("step-dock", "dock", $"Dock at {station.Label}",
[
@@ -1220,13 +1219,13 @@ public sealed class ShipAiService(
}
ship.State = ShipState.Mining;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.MiningCycleSeconds))
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.MiningCycleSeconds))
{
return SubTaskOutcome.Active;
}
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity);
var mined = MathF.Min(balance.MiningRate * GetSkillFactor(ship.Skills.Mining), remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
@@ -1282,7 +1281,7 @@ public sealed class ShipAiService(
}
ship.State = ShipState.Docking;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.DockingDuration))
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.DockingDuration))
{
return SubTaskOutcome.Active;
}
@@ -1311,16 +1310,16 @@ public sealed class ShipAiService(
return SubTaskOutcome.Completed;
}
var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
var undockTarget = GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, balance.UndockDistance);
ship.TargetPosition = undockTarget;
ship.State = ShipState.Undocking;
if (!AdvanceTimedSubTask(subTask, deltaSeconds, world.Balance.UndockingDuration))
if (!AdvanceTimedSubTask(subTask, deltaSeconds, balance.UndockingDuration))
{
ship.Position = GetShipDockedPosition(ship, station);
return SubTaskOutcome.Active;
}
ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance);
ship.Position = ship.Position.MoveToward(undockTarget, balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > MathF.Max(subTask.Threshold, 4f))
{
return SubTaskOutcome.Active;
@@ -1359,7 +1358,7 @@ public sealed class ShipAiService(
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : ship.Definition.CargoCapacity;
var availableCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Trade);
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Trade);
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(availableCapacity, GetInventoryAmount(station.Inventory, itemId)));
if (moved > 0.01f)
{
@@ -1392,7 +1391,7 @@ public sealed class ShipAiService(
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.State = ShipState.Transferring;
var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining));
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Mining));
if (subTask.ItemId is not null)
{
@@ -1451,7 +1450,7 @@ public sealed class ShipAiService(
return SubTaskOutcome.Failed;
}
var transferRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation));
var transferRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Trade, ship.Skills.Navigation));
var desiredAmount = subTask.Amount > 0f ? subTask.Amount : GetInventoryAmount(ship.Inventory, subTask.ItemId);
var moved = MathF.Min(transferRate * deltaSeconds, MathF.Min(targetCapacity, GetInventoryAmount(ship.Inventory, subTask.ItemId)));
if (moved > 0.01f)
@@ -1491,12 +1490,12 @@ public sealed class ShipAiService(
return SubTaskOutcome.Completed;
}
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, world.Balance.MiningCycleSeconds * 0.8f)))
if (!AdvanceTimedSubTask(subTask, deltaSeconds, MathF.Max(0.4f, balance.MiningCycleSeconds * 0.8f)))
{
return SubTaskOutcome.Active;
}
var salvageRate = world.Balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade));
var salvageRate = balance.TransferRate * GetSkillFactor(Math.Max(ship.Skills.Mining, ship.Skills.Trade));
var recovered = MathF.Min(salvageRate, MathF.Min(remainingCapacity, wreck.RemainingAmount));
if (recovered > 0.01f)
{
@@ -1537,7 +1536,7 @@ public sealed class ShipAiService(
ship.TargetPosition = supportPosition;
ship.Position = supportPosition;
ship.State = ShipState.DeliveringConstruction;
var transferRate = world.Balance.TransferRate * GetSkillFactor(ship.Skills.Construction);
var transferRate = balance.TransferRate * GetSkillFactor(ship.Skills.Construction);
foreach (var required in site.RequiredItems.OrderBy(entry => entry.Key, StringComparer.Ordinal))
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
@@ -1654,7 +1653,7 @@ public sealed class ShipAiService(
ship.SpatialState.DestinationNodeId = targetCelestial?.Id;
subTask.Progress = Math.Clamp(1f - (distance / MathF.Max(distance + GetLocalTravelSpeed(ship), 1f)), 0f, 1f);
if (distance <= MathF.Max(subTask.Threshold, world.Balance.ArrivalThreshold))
if (distance <= MathF.Max(subTask.Threshold, balance.ArrivalThreshold))
{
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;

View File

@@ -0,0 +1,16 @@
namespace SpaceGame.Api.Ships.Simulation;
internal static class ShipBootstrapPolicy
{
internal static ShipSkillProfileRuntime CreateSkills(ShipDefinition definition)
{
return definition.Kind switch
{
"transport" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 4, Mining = 1, Combat = 1, Construction = 1 },
"construction" => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 1, Combat = 1, Construction = 4 },
"military" => new ShipSkillProfileRuntime { Navigation = 4, Trade = 1, Mining = 1, Combat = 4, Construction = 1 },
_ when SpaceGame.Api.Universe.Scenario.LoaderSupport.HasCapabilities(definition, "mining") => new ShipSkillProfileRuntime { Navigation = 3, Trade = 1, Mining = 4, Combat = 1, Construction = 1 },
_ => new ShipSkillProfileRuntime { Navigation = 3, Trade = 2, Mining = 1, Combat = 1, Construction = 1 },
};
}
}

View File

@@ -1,7 +1,8 @@
namespace SpaceGame.Api.Simulation.Core;
public sealed class SimulationEngine
internal sealed class SimulationEngine
{
private readonly IBalanceService _balance;
private readonly OrbitalSimulationOptions _orbitalSimulation;
private readonly OrbitalStateUpdater _orbitalStateUpdater;
private readonly InfrastructureSimulationService _infrastructureSimulation;
@@ -13,9 +14,10 @@ public sealed class SimulationEngine
private readonly ShipAiService _shipAi;
private readonly SimulationProjectionService _projection;
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
internal SimulationEngine(OrbitalSimulationOptions orbitalSimulation, IBalanceService balance)
{
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
_balance = balance;
_orbitalSimulation = orbitalSimulation;
_orbitalStateUpdater = new OrbitalStateUpdater(_orbitalSimulation);
_infrastructureSimulation = new InfrastructureSimulationService();
_geopolitics = new GeopoliticalSimulationService();
@@ -23,7 +25,7 @@ public sealed class SimulationEngine
_playerFaction = new PlayerFactionService();
_stationSimulation = new StationSimulationService();
_stationLifecycle = new StationLifecycleService(_stationSimulation);
_shipAi = new ShipAiService();
_shipAi = new ShipAiService(balance);
_projection = new SimulationProjectionService(_orbitalSimulation);
}
@@ -31,7 +33,7 @@ public sealed class SimulationEngine
{
var nowUtc = DateTimeOffset.UtcNow;
var events = new List<SimulationEventRecord>();
var simulationDeltaSeconds = deltaSeconds * MathF.Max(world.Balance.SimulationSpeedMultiplier, 0.01f);
var simulationDeltaSeconds = deltaSeconds * MathF.Max(_balance.SimulationSpeedMultiplier, 0.01f);
world.GeneratedAtUtc = nowUtc;
world.OrbitalTimeSeconds += simulationDeltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;

View File

@@ -1,4 +1,5 @@
using SpaceGame.Api.Shared.Runtime;
using SpaceGame.Api.Ships.Simulation;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Stations.Simulation;
@@ -79,7 +80,7 @@ internal sealed class StationLifecycleService
TargetPosition = spawnPosition,
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
Skills = WorldSeedingService.CreateSkills(definition),
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.MaxHealth,
};

View File

@@ -3,7 +3,7 @@ using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetBalanceHandler(WorldService worldService) : EndpointWithoutRequest
public sealed class GetBalanceHandler(IBalanceService balanceService) : EndpointWithoutRequest
{
public override void Configure()
{
@@ -12,5 +12,5 @@ public sealed class GetBalanceHandler(WorldService worldService) : EndpointWitho
}
public override Task HandleAsync(CancellationToken cancellationToken) =>
SendOkAsync(worldService.GetBalance(), cancellationToken);
SendOkAsync(balanceService.GetCurrent(), cancellationToken);
}

View File

@@ -3,7 +3,7 @@ using SpaceGame.Api.Universe.Simulation;
namespace SpaceGame.Api.Universe.Api;
public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<BalanceOptions>
public sealed class UpdateBalanceHandler(IBalanceService balanceService) : Endpoint<BalanceOptions>
{
public override void Configure()
{
@@ -13,7 +13,7 @@ public sealed class UpdateBalanceHandler(WorldService worldService) : Endpoint<B
public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken)
{
var applied = worldService.UpdateBalance(req);
var applied = balanceService.Update(req);
return SendOkAsync(applied, cancellationToken);
}
}

View File

@@ -0,0 +1,14 @@
namespace SpaceGame.Api.Universe.Bootstrap;
public interface IStaticDataProvider
{
IReadOnlyList<SolarSystemDefinition> KnownSystems { get; }
IReadOnlyDictionary<string, RaceDefinition> RaceDefinitions { get; }
IReadOnlyDictionary<string, FactionDefinition> FactionDefinitions { get; }
IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions { get; }
IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions { get; }
IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions { get; }
IReadOnlyDictionary<string, RecipeDefinition> Recipes { get; }
IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; }
ProductionGraph ProductionGraph { get; }
}

View File

@@ -1,9 +0,0 @@
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

@@ -2,5 +2,5 @@ namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class StaticDataOptions
{
public required string DataRoot { get; init; }
public required string DataRoot { get; set; }
}

View File

@@ -4,34 +4,59 @@ using SpaceGame.Api.Shared.Runtime;
namespace SpaceGame.Api.Universe.Bootstrap;
internal sealed class StaticDataLoader(IOptions<StaticDataOptions> staticDataOptions)
public sealed class StaticDataProvider : IStaticDataProvider
{
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
internal StaticDataCatalog Load()
public StaticDataProvider(IOptions<StaticDataOptions> staticDataOptions)
{
_dataRoot = staticDataOptions.Value.DataRoot;
var knownSystems = Read<List<SolarSystemDefinition>>("systems.json");
var races = Read<List<RaceDefinition>>("races.json");
var factions = Read<List<FactionDefinition>>("factions.json");
var modules = NormalizeModules(Read<List<ModuleDefinition>>("modules.json"));
var ships = Read<List<ShipDefinition>>("ships.json");
var items = Read<List<ItemDefinition>>("items.json");
var recipes = BuildRecipes(items, ships, modules);
var moduleRecipes = BuildModuleRecipes(modules);
var productionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
return new StaticDataCatalog(
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),
productionGraph);
KnownSystems = knownSystems;
RaceDefinitions = races.ToDictionary(definition => definition.Id, StringComparer.Ordinal);
FactionDefinitions = factions.ToDictionary(definition => definition.Id, StringComparer.Ordinal);
ModuleDefinitions = modules.ToDictionary(definition => definition.Id, StringComparer.Ordinal);
ShipDefinitions = ships.ToDictionary(definition => definition.Id, StringComparer.Ordinal);
ItemDefinitions = items.ToDictionary(definition => definition.Id, StringComparer.Ordinal);
Recipes = recipes.ToDictionary(definition => definition.Id, StringComparer.Ordinal);
ModuleRecipes = moduleRecipes.ToDictionary(definition => definition.ModuleId, StringComparer.Ordinal);
ProductionGraph = ProductionGraphBuilder.Build(items, recipes, modules);
}
public IReadOnlyList<SolarSystemDefinition> KnownSystems { get; }
public IReadOnlyDictionary<string, RaceDefinition> RaceDefinitions { get; }
public IReadOnlyDictionary<string, FactionDefinition> FactionDefinitions { get; }
public IReadOnlyDictionary<string, ModuleDefinition> ModuleDefinitions { get; }
public IReadOnlyDictionary<string, ShipDefinition> ShipDefinitions { get; }
public IReadOnlyDictionary<string, ItemDefinition> ItemDefinitions { get; }
public IReadOnlyDictionary<string, RecipeDefinition> Recipes { get; }
public IReadOnlyDictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; }
public ProductionGraph ProductionGraph { get; }
private T Read<T>(string fileName)
{
var path = Path.Combine(staticDataOptions.Value.DataRoot, fileName);
var path = Path.Combine(_dataRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
?? throw new InvalidOperationException($"Unable to read {fileName}.");
@@ -133,28 +158,26 @@ internal sealed class StaticDataLoader(IOptions<StaticDataOptions> staticDataOpt
foreach (var ship in ships)
{
if (ship.Construction is null)
foreach (var production in ship.Production)
{
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
recipes.Add(new RecipeDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
ShipOutputId = ship.Id,
});
Id = $"{ship.Id}-{production.Method}-construction",
Label = $"{ship.Label} Construction",
FacilityCategory = "shipyard",
Duration = production.Time,
Priority = InferShipRecipePriority(ship),
RequiredModules = InferShipBuildModules(ship),
Inputs = production.Wares
.Select(input => new RecipeInputDefinition
{
ItemId = input.ItemId,
Amount = input.Amount,
})
.ToList(),
ShipOutputId = ship.Id,
});
}
}
return recipes;
@@ -191,6 +214,25 @@ internal sealed class StaticDataLoader(IOptions<StaticDataOptions> staticDataOpt
_ => 60,
};
private static List<string> InferShipBuildModules(ShipDefinition ship) =>
ship.Size switch
{
"extrasmall" or "small" or "medium" => ["module_gen_build_dockarea_m_01"],
"large" => ["module_gen_build_l_01"],
"extralarge" => ["module_gen_build_xl_01"],
_ => ["module_gen_build_dockarea_m_01"],
};
private static int InferShipRecipePriority(ShipDefinition ship) =>
ship.Kind switch
{
"military" => 170,
"construction" => 140,
"transport" => 120,
"mining" => 110,
_ => 100,
};
private static List<ModuleDefinition> NormalizeModules(List<ModuleDefinition> modules)
{
for (var index = 0; index < modules.Count; index += 1)

View File

@@ -1,20 +0,0 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,165 @@
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class BalanceService : IBalanceService
{
private readonly Lock _sync = new();
private BalanceOptions _current;
public BalanceService(IOptions<BalanceOptions> defaults)
{
_current = Sanitize(defaults.Value);
}
public float SimulationSpeedMultiplier
{
get
{
lock (_sync)
{
return _current.SimulationSpeedMultiplier;
}
}
}
public float YPlane
{
get
{
lock (_sync)
{
return _current.YPlane;
}
}
}
public float ArrivalThreshold
{
get
{
lock (_sync)
{
return _current.ArrivalThreshold;
}
}
}
public float MiningRate
{
get
{
lock (_sync)
{
return _current.MiningRate;
}
}
}
public float MiningCycleSeconds
{
get
{
lock (_sync)
{
return _current.MiningCycleSeconds;
}
}
}
public float TransferRate
{
get
{
lock (_sync)
{
return _current.TransferRate;
}
}
}
public float DockingDuration
{
get
{
lock (_sync)
{
return _current.DockingDuration;
}
}
}
public float UndockingDuration
{
get
{
lock (_sync)
{
return _current.UndockingDuration;
}
}
}
public float UndockDistance
{
get
{
lock (_sync)
{
return _current.UndockDistance;
}
}
}
public BalanceOptions GetCurrent()
{
lock (_sync)
{
return Clone(_current);
}
}
public BalanceOptions Update(BalanceOptions candidate)
{
lock (_sync)
{
_current = Sanitize(candidate);
return Clone(_current);
}
}
private static BalanceOptions Clone(BalanceOptions value)
{
return new BalanceOptions
{
SimulationSpeedMultiplier = value.SimulationSpeedMultiplier,
YPlane = value.YPlane,
ArrivalThreshold = value.ArrivalThreshold,
MiningRate = value.MiningRate,
MiningCycleSeconds = value.MiningCycleSeconds,
TransferRate = value.TransferRate,
DockingDuration = value.DockingDuration,
UndockingDuration = value.UndockingDuration,
UndockDistance = value.UndockDistance,
};
}
private static BalanceOptions Sanitize(BalanceOptions candidate)
{
static float finiteOr(float value, float fallback) =>
float.IsFinite(value) ? value : fallback;
return new BalanceOptions
{
SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)),
YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)),
ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)),
MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)),
MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)),
TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)),
DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)),
UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)),
UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)),
};
}
}

View File

@@ -0,0 +1,16 @@
namespace SpaceGame.Api.Universe.Simulation;
public interface IBalanceService
{
float SimulationSpeedMultiplier { get; }
float YPlane { get; }
float ArrivalThreshold { get; }
float MiningRate { get; }
float MiningCycleSeconds { get; }
float TransferRate { get; }
float DockingDuration { get; }
float UndockingDuration { get; }
float UndockDistance { get; }
BalanceOptions GetCurrent();
BalanceOptions Update(BalanceOptions candidate);
}

View File

@@ -0,0 +1,13 @@
namespace SpaceGame.Api.Universe.Simulation;
public interface ITimeService
{
int TickIntervalMs { get; }
TimeSpan TickInterval { get; }
float TickDeltaSeconds { get; }
double SimulatedSecondsPerRealSecond { get; }
DateTimeOffset UtcNow();
float ScaleSimulationDelta(float realDeltaSeconds);
double ScaleOrbitalDelta(float simulationDeltaSeconds);
double CreateInitialOrbitalTimeSeconds(int seed);
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class TimeService(
IBalanceService balance,
IOptions<OrbitalSimulationOptions> orbitalSimulation) : ITimeService
{
public int TickIntervalMs => 200;
public TimeSpan TickInterval => TimeSpan.FromMilliseconds(TickIntervalMs);
public float TickDeltaSeconds => TickIntervalMs / 1000f;
public double SimulatedSecondsPerRealSecond => orbitalSimulation.Value.SimulatedSecondsPerRealSecond;
public DateTimeOffset UtcNow() => DateTimeOffset.UtcNow;
public float ScaleSimulationDelta(float realDeltaSeconds) =>
realDeltaSeconds * MathF.Max(balance.SimulationSpeedMultiplier, 0.01f);
public double ScaleOrbitalDelta(float simulationDeltaSeconds) =>
simulationDeltaSeconds * SimulatedSecondsPerRealSecond;
public double CreateInitialOrbitalTimeSeconds(int seed) => seed * 97d;
}

View File

@@ -2,7 +2,9 @@ namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldGenerationOptions
{
public int Seed { get; init; }
public int TargetSystemCount { get; init; }
public bool UseKnownSystems { get; init; }
public int AiControllerFactionCount { get; init; }
public bool GeneratePlayerFaction { get; init; }
}

View File

@@ -1,26 +1,57 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Scenario;
namespace SpaceGame.Api.Universe.Simulation;
public sealed class WorldService(
WorldBootstrapper worldBootstrapper,
IOptions<BalanceOptions> balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
public sealed class WorldService
{
private const int DeltaHistoryLimit = 256;
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly WorldBootstrapper _bootstrapper = worldBootstrapper;
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
private readonly SimulationEngine _engine;
private readonly ScenarioLoader _scenarioLoader;
private readonly WorldBuilder _worldBuilder;
private readonly PlayerFactionService _playerFaction = new();
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = worldBootstrapper.Bootstrap();
private SimulationWorld _world = null!;
private string? _currentScenarioPath;
private WorldGenerationOptions? _currentWorldGenerationOptions;
private long _sequence;
private BalanceOptions? _balanceOverride;
public WorldService(
ScenarioLoader scenarioLoader,
WorldBuilder worldBuilder,
IBalanceService balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{
_orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
_scenarioLoader = scenarioLoader;
_worldBuilder = worldBuilder;
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance);
}
public void New(WorldGenerationOptions options)
{
lock (_sync)
{
_currentScenarioPath = null;
_currentWorldGenerationOptions = options;
ReplaceWorldUnsafe(_worldBuilder.BuildFromGeneration(options), "new", "Generated new world");
}
}
public void LoadFromScenario(string scenarioPath)
{
lock (_sync)
{
_currentScenarioPath = scenarioPath;
_currentWorldGenerationOptions = null;
ReplaceWorldUnsafe(_worldBuilder.BuildFromScenario(_scenarioLoader.Load(scenarioPath)), "load-scenario", $"Loaded scenario {scenarioPath}");
}
}
public WorldSnapshot GetSnapshot()
{
@@ -46,35 +77,6 @@ public sealed class WorldService(
}
}
public BalanceOptions GetBalance()
{
lock (_sync)
{
return new BalanceOptions
{
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 BalanceOptions UpdateBalance(BalanceOptions balance)
{
lock (_sync)
{
_balanceOverride = SanitizeBalance(balance);
ApplyBalance(_balanceOverride);
return GetBalance();
}
}
public ShipSnapshot? EnqueueShipOrder(string shipId, ShipOrderCommandRequest request)
{
lock (_sync)
@@ -121,6 +123,11 @@ public sealed class WorldService(
{
lock (_sync)
{
if (_world.PlayerFaction is null && _world.Factions.Count == 0)
{
return null;
}
_playerFaction.EnsureDomain(_world);
return GetPlayerFactionSnapshotUnsafe();
}
@@ -285,74 +292,61 @@ public sealed class WorldService(
{
lock (_sync)
{
_world = _bootstrapper.Bootstrap();
if (_balanceOverride is not null)
if (_currentScenarioPath is not null)
{
ApplyBalance(_balanceOverride);
ReplaceWorldUnsafe(
_worldBuilder.BuildFromScenario(_scenarioLoader.Load(_currentScenarioPath)),
"reset",
"World reset requested");
}
_sequence += 1;
_history.Clear();
var resetDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
DateTimeOffset.UtcNow,
true,
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],
[],
[],
[],
[],
[],
[],
[],
[],
[],
null,
null);
_history.Enqueue(resetDelta);
foreach (var subscriber in _subscribers.Values.ToList())
else if (_currentWorldGenerationOptions is not null)
{
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(resetDelta, subscriber.Scope));
ReplaceWorldUnsafe(
_worldBuilder.BuildFromGeneration(_currentWorldGenerationOptions),
"reset",
"World reset requested");
}
else
{
throw new InvalidOperationException("No world source is configured.");
}
return _engine.BuildSnapshot(_world, _sequence);
}
}
private void ApplyBalance(BalanceOptions value)
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
{
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;
}
_world = world;
_sequence += 1;
_history.Clear();
private static BalanceOptions SanitizeBalance(BalanceOptions candidate)
{
static float finiteOr(float value, float fallback) =>
float.IsFinite(value) ? value : fallback;
var eventTime = DateTimeOffset.UtcNow;
var worldDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
eventTime,
true,
[new SimulationEventRecord("world", "world", eventKind, eventMessage, eventTime, "world", "universe", "world")],
[],
[],
[],
[],
[],
[],
[],
[],
[],
null,
null);
return new BalanceOptions
_history.Enqueue(worldDelta);
foreach (var subscriber in _subscribers.Values.ToList())
{
SimulationSpeedMultiplier = MathF.Max(0.01f, finiteOr(candidate.SimulationSpeedMultiplier, 1f)),
YPlane = MathF.Max(0f, finiteOr(candidate.YPlane, 0f)),
ArrivalThreshold = MathF.Max(0.1f, finiteOr(candidate.ArrivalThreshold, 16f)),
MiningRate = MathF.Max(0f, finiteOr(candidate.MiningRate, 10f)),
MiningCycleSeconds = MathF.Max(0.1f, finiteOr(candidate.MiningCycleSeconds, 10f)),
TransferRate = MathF.Max(0f, finiteOr(candidate.TransferRate, 56f)),
DockingDuration = MathF.Max(0.1f, finiteOr(candidate.DockingDuration, 1.2f)),
UndockingDuration = MathF.Max(0.1f, finiteOr(candidate.UndockingDuration, 1.2f)),
UndockDistance = MathF.Max(0f, finiteOr(candidate.UndockDistance, 42f)),
};
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope));
}
}
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>

View File

@@ -7,7 +7,7 @@
},
"WorldGeneration": {
"TargetSystemCount": 2,
"IncludeSolSystem": true,
"UseKnownSystems": true,
"AiControllerFactionCount": 0,
"GeneratePlayerFaction": false
},

View File

@@ -5,9 +5,12 @@
"Microsoft.AspNetCore": "Warning"
}
},
"StaticData": {
"DataRoot": "../../shared/data/"
},
"WorldGeneration": {
"TargetSystemCount": 160,
"IncludeSolSystem": true
"UseKnownSystems": true
},
"Balance": {
"SimulationSpeedMultiplier": 1.5,