Refactor world bootstrap and allow empty startup worlds
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
16
apps/backend/Ships/Simulation/ShipBootstrapPolicy.cs
Normal file
16
apps/backend/Ships/Simulation/ShipBootstrapPolicy.cs
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
14
apps/backend/Universe/Bootstrap/IStaticDataProvider.cs
Normal file
14
apps/backend/Universe/Bootstrap/IStaticDataProvider.cs
Normal 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; }
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
289
apps/backend/Universe/Scenario/ScenarioContentBuilder.cs
Normal file
289
apps/backend/Universe/Scenario/ScenarioContentBuilder.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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}.");
|
||||
}
|
||||
}
|
||||
|
||||
111
apps/backend/Universe/Scenario/ScenarioValidationService.cs
Normal file
111
apps/backend/Universe/Scenario/ScenarioValidationService.cs
Normal 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}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
5
apps/backend/Universe/Scenario/ScenarioWorldContent.cs
Normal file
5
apps/backend/Universe/Scenario/ScenarioWorldContent.cs
Normal file
@@ -0,0 +1,5 @@
|
||||
namespace SpaceGame.Api.Universe.Scenario;
|
||||
|
||||
public sealed record ScenarioWorldContent(
|
||||
IReadOnlyList<StationRuntime> Stations,
|
||||
IReadOnlyList<ShipRuntime> Ships);
|
||||
@@ -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);
|
||||
|
||||
145
apps/backend/Universe/Scenario/StarterStationLayoutResolver.cs
Normal file
145
apps/backend/Universe/Scenario/StarterStationLayoutResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
7
apps/backend/Universe/Scenario/WorldBuildTopology.cs
Normal file
7
apps/backend/Universe/Scenario/WorldBuildTopology.cs
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
62
apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs
Normal file
62
apps/backend/Universe/Scenario/WorldRuntimeAssembler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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..]));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
29
apps/backend/Universe/Scenario/WorldTopologyBuilder.cs
Normal file
29
apps/backend/Universe/Scenario/WorldTopologyBuilder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
165
apps/backend/Universe/Simulation/BalanceService.cs
Normal file
165
apps/backend/Universe/Simulation/BalanceService.cs
Normal 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
16
apps/backend/Universe/Simulation/IBalanceService.cs
Normal file
16
apps/backend/Universe/Simulation/IBalanceService.cs
Normal 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);
|
||||
}
|
||||
13
apps/backend/Universe/Simulation/ITimeService.cs
Normal file
13
apps/backend/Universe/Simulation/ITimeService.cs
Normal 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);
|
||||
}
|
||||
23
apps/backend/Universe/Simulation/TimeService.cs
Normal file
23
apps/backend/Universe/Simulation/TimeService.cs
Normal 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"WorldGeneration": {
|
||||
"TargetSystemCount": 2,
|
||||
"IncludeSolSystem": true,
|
||||
"UseKnownSystems": true,
|
||||
"AiControllerFactionCount": 0,
|
||||
"GeneratePlayerFaction": false
|
||||
},
|
||||
|
||||
@@ -5,9 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"StaticData": {
|
||||
"DataRoot": "../../shared/data/"
|
||||
},
|
||||
"WorldGeneration": {
|
||||
"TargetSystemCount": 160,
|
||||
"IncludeSolSystem": true
|
||||
"UseKnownSystems": true
|
||||
},
|
||||
"Balance": {
|
||||
"SimulationSpeedMultiplier": 1.5,
|
||||
|
||||
Reference in New Issue
Block a user