Refactor runtime bootstrap and ship control flows

This commit is contained in:
2026-04-03 01:12:26 -04:00
parent 0bb72bee35
commit 706e1cda8f
129 changed files with 9588 additions and 3548 deletions

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class CreateFactionHandler(WorldService worldService) : Endpoint<CreateFactionCommandRequest, FactionSnapshot>
{
public override void Configure()
{
Post("/api/gm/factions");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(CreateFactionCommandRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(worldService.CreateFaction(request.FactionId), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -8,7 +8,7 @@ public sealed class GetBalanceHandler(IBalanceService balanceService) : Endpoint
public override void Configure()
{
Get("/api/balance");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(CancellationToken cancellationToken) =>

View File

@@ -9,7 +9,7 @@ public sealed class GetTelemetryHandler(TelemetryService telemetry, WorldService
public override void Configure()
{
Get("/api/telemetry");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(CancellationToken cancellationToken)

View File

@@ -0,0 +1,17 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class GetVersionHandler(AppVersionService appVersionService) : EndpointWithoutRequest<VersionInfoSnapshot>
{
public override void Configure()
{
Get("/api/version");
AllowAnonymous();
}
public override async Task HandleAsync(CancellationToken cancellationToken)
{
await SendOkAsync(appVersionService.GetSnapshot(), cancellationToken);
}
}

View File

@@ -7,7 +7,7 @@ public sealed class ResetWorldHandler(WorldService worldService) : EndpointWitho
public override void Configure()
{
Post("/api/world/reset");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(CancellationToken cancellationToken) =>

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class SpawnShipHandler(WorldService worldService) : Endpoint<SpawnShipCommandRequest, ShipSnapshot>
{
public override void Configure()
{
Post("/api/gm/ships");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(SpawnShipCommandRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(worldService.SpawnShip(request), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -0,0 +1,25 @@
using FastEndpoints;
namespace SpaceGame.Api.Universe.Api;
public sealed class SpawnStationHandler(WorldService worldService) : Endpoint<SpawnStationCommandRequest, StationSnapshot>
{
public override void Configure()
{
Post("/api/gm/stations");
Policies(AuthPolicyNames.GmAccess);
}
public override async Task HandleAsync(SpawnStationCommandRequest request, CancellationToken cancellationToken)
{
try
{
await SendOkAsync(worldService.SpawnStation(request), cancellationToken);
}
catch (InvalidOperationException ex)
{
AddError(ex.Message);
await SendErrorsAsync(cancellation: cancellationToken);
}
}
}

View File

@@ -8,7 +8,7 @@ public sealed class UpdateBalanceHandler(IBalanceService balanceService) : Endpo
public override void Configure()
{
Put("/api/balance");
AllowAnonymous();
Policies(AuthPolicyNames.GmAccess);
}
public override Task HandleAsync(BalanceOptions req, CancellationToken cancellationToken)

View File

@@ -1,15 +1,22 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Shared.Runtime;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Universe.Bootstrap;
public sealed class StaticDataProvider : IStaticDataProvider
{
private const string MilitaryShipCategory = "military";
private const string ConstructionShipCategory = "construction";
private const string TransportShipCategory = "transport";
private const string MiningShipCategory = "mining";
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter() },
};
public StaticDataProvider(IOptions<StaticDataOptions> staticDataOptions)
@@ -163,7 +170,7 @@ public sealed class StaticDataProvider : IStaticDataProvider
recipes.Add(new RecipeDefinition
{
Id = $"{ship.Id}-{production.Method}-construction",
Label = $"{ship.Label} Construction",
Label = $"{ship.Name} Construction",
FacilityCategory = "shipyard",
Duration = production.Time,
Priority = InferShipRecipePriority(ship),
@@ -224,12 +231,12 @@ public sealed class StaticDataProvider : IStaticDataProvider
};
private static int InferShipRecipePriority(ShipDefinition ship) =>
ship.Kind switch
GetShipCategory(ship) switch
{
"military" => 170,
"construction" => 140,
"transport" => 120,
"mining" => 110,
MilitaryShipCategory => 170,
ConstructionShipCategory => 140,
TransportShipCategory => 120,
MiningShipCategory => 110,
_ => 100,
};

View File

@@ -0,0 +1,16 @@
namespace SpaceGame.Api.Universe.Contracts;
public sealed record CreateFactionCommandRequest(
string FactionId);
public sealed record SpawnShipCommandRequest(
string FactionId,
string SystemId,
string? ShipId = null,
string? BehaviorKind = null);
public sealed record SpawnStationCommandRequest(
string FactionId,
string SystemId,
string? Objective = null,
string? Label = null);

View File

@@ -18,7 +18,6 @@ public sealed record WorldSnapshot(
IReadOnlyList<PolicySetSnapshot> Policies,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions,
PlayerFactionSnapshot? PlayerFaction,
GeopoliticalStateSnapshot? Geopolitics);
public sealed record WorldDelta(
@@ -38,7 +37,6 @@ public sealed record WorldDelta(
IReadOnlyList<PolicySetDelta> Policies,
IReadOnlyList<ShipDelta> Ships,
IReadOnlyList<FactionDelta> Factions,
PlayerFactionSnapshot? PlayerFaction,
GeopoliticalStateSnapshot? Geopolitics,
ObserverScope? Scope = null);

View File

@@ -89,9 +89,6 @@ internal static class LoaderSupport
internal static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.Modules.Any(candidate => string.Equals(candidate.ModuleId, moduleId, StringComparison.Ordinal)));
internal static bool HasCapabilities(ShipDefinition definition, params string[] capabilities) =>
capabilities.All(capability => definition.Capabilities.Contains(capability, StringComparer.Ordinal));
internal static void AddStationModule(StationRuntime station, IReadOnlyDictionary<string, ModuleDefinition> moduleDefinitions, string moduleId)
{
if (!moduleDefinitions.TryGetValue(moduleId, out var definition))

View File

@@ -1,5 +1,7 @@
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Ships.Simulation;
using SpaceGame.Api.Ships.AI;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
@@ -194,7 +196,7 @@ public sealed class ScenarioContentBuilder(
patrolRoutes,
stations),
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.MaxHealth,
Health = definition.Hull,
});
foreach (var (itemId, amount) in formation.StartingInventory)
@@ -232,45 +234,45 @@ public sealed class ScenarioContentBuilder(
&& 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)
if (IsConstructionShip(definition) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
Kind = ConstructStation,
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
PreferredConstructionSiteId = null,
};
}
if (LoaderSupport.HasCapabilities(definition, "mining") && homeStation is not null)
if (IsMiningShip(definition) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = definition.CargoCapacity >= 120f ? "expert-auto-mine" : "advanced-auto-mine",
Kind = definition.GetTotalCargoCapacity() >= 120f ? ExpertAutoMine : AdvancedAutoMine,
HomeSystemId = homeStation.SystemId,
HomeStationId = homeStation.Id,
AreaSystemId = homeStation.SystemId,
MaxSystemRange = definition.CargoCapacity >= 120f ? 3 : 1,
MaxSystemRange = definition.GetTotalCargoCapacity() >= 120f ? 3 : 1,
};
}
if (string.Equals(definition.Kind, "transport", StringComparison.Ordinal))
if (IsTransportShip(definition))
{
return new DefaultBehaviorRuntime
{
Kind = "advanced-auto-trade",
Kind = AdvancedAutoTrade,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
MaxSystemRange = 2,
};
}
if (string.Equals(definition.Kind, "military", StringComparison.Ordinal) && patrolRoutes.TryGetValue(systemId, out var route))
if (IsMilitaryShip(definition) && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
Kind = Patrol,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
@@ -281,9 +283,10 @@ public sealed class ScenarioContentBuilder(
return new DefaultBehaviorRuntime
{
Kind = "idle",
Kind = HoldPosition,
HomeSystemId = homeStation?.SystemId ?? systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = homeStation?.SystemId ?? systemId,
};
}
}

View File

@@ -520,6 +520,8 @@ public sealed class SystemGenerationService
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
// Cheap deterministic pseudo-random helper: same (index, salt) pair always maps to the same 0..1 value.
// Generation code uses it instead of a mutable RNG so each procedural choice stays stable for a given seed.
private static float Hash01(int index, int salt)
{
uint value = (uint)(index + 1);

View File

@@ -18,9 +18,6 @@ public sealed class WorldRuntimeAssembler(
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
@@ -34,7 +31,6 @@ public sealed class WorldRuntimeAssembler(
Stations = content.Stations.ToList(),
Ships = content.Ships.ToList(),
Factions = factions,
PlayerFaction = playerFaction,
Geopolitics = null,
Commanders = commanders,
Claims = claims,

View File

@@ -1,4 +1,5 @@
using SpaceGame.Api.Universe.Bootstrap;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Universe.Scenario.LoaderSupport;
namespace SpaceGame.Api.Universe.Scenario;
@@ -379,7 +380,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
Label = "Core Automation",
ScopeKind = "player-faction",
ScopeId = player.Id,
BehaviorKind = "idle",
BehaviorKind = Idle,
UpdatedAtUtc = nowUtc,
});
@@ -395,7 +396,7 @@ public sealed class WorldSeedingService(IStaticDataProvider staticData)
return player;
}
private FactionRuntime CreateFaction(string factionId)
internal FactionRuntime CreateFaction(string factionId)
{
if (!staticData.FactionDefinitions.TryGetValue(factionId, out var definition))
{

View File

@@ -1,6 +1,9 @@
using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Api.Universe.Bootstrap;
using SpaceGame.Api.Universe.Scenario;
using static SpaceGame.Api.Shared.Runtime.ShipBehaviorKinds;
using static SpaceGame.Api.Shared.Runtime.SimulationRuntimeSupport;
namespace SpaceGame.Api.Universe.Simulation;
@@ -11,8 +14,13 @@ public sealed class WorldService
private readonly Lock _sync = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation;
private readonly SimulationEngine _engine;
private readonly IPlayerIdentityResolver _playerIdentityResolver;
private readonly IPlayerStateStore _playerStateStore;
private readonly PlayerFactionProjectionService _playerFactionProjection;
private readonly ScenarioLoader _scenarioLoader;
private readonly WorldBuilder _worldBuilder;
private readonly IStaticDataProvider _staticData;
private readonly WorldSeedingService _worldSeedingService;
private readonly PlayerFactionService _playerFaction = new();
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = [];
@@ -24,13 +32,23 @@ public sealed class WorldService
public WorldService(
ScenarioLoader scenarioLoader,
WorldBuilder worldBuilder,
IStaticDataProvider staticData,
WorldSeedingService worldSeedingService,
IPlayerStateStore playerStateStore,
IPlayerIdentityResolver playerIdentityResolver,
PlayerFactionProjectionService playerFactionProjection,
IBalanceService balance,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{
_orbitalSimulation = new OrbitalSimulationSnapshot(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
_playerStateStore = playerStateStore;
_playerIdentityResolver = playerIdentityResolver;
_playerFactionProjection = playerFactionProjection;
_scenarioLoader = scenarioLoader;
_worldBuilder = worldBuilder;
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance);
_staticData = staticData;
_worldSeedingService = worldSeedingService;
_engine = new SimulationEngine(orbitalSimulationOptions.Value, balance, playerStateStore);
}
public void New(WorldGenerationOptions options)
@@ -81,7 +99,10 @@ public sealed class WorldService
{
lock (_sync)
{
var ship = _playerFaction.EnqueueDirectShipOrder(_world, shipId, request);
ValidateShipOrderRequestUnsafe(shipId, request);
var ship = CanCurrentActorAccessGm()
? EnqueueGmShipOrderUnsafe(shipId, request)
: _playerFaction.EnqueueDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request);
if (ship is null)
{
return null;
@@ -95,7 +116,9 @@ public sealed class WorldService
{
lock (_sync)
{
var ship = _playerFaction.RemoveDirectShipOrder(_world, shipId, orderId);
var ship = CanCurrentActorAccessGm()
? RemoveGmShipOrderUnsafe(shipId, orderId)
: _playerFaction.RemoveDirectShipOrder(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, orderId);
if (ship is null)
{
return null;
@@ -109,7 +132,9 @@ public sealed class WorldService
{
lock (_sync)
{
var ship = _playerFaction.ConfigureDirectShipBehavior(_world, shipId, request);
var ship = CanCurrentActorAccessGm()
? ConfigureGmShipBehaviorUnsafe(shipId, request)
: _playerFaction.ConfigureDirectShipBehavior(_world, _playerStateStore, GetCurrentPlayerKey(), shipId, request);
if (ship is null)
{
return null;
@@ -123,13 +148,15 @@ public sealed class WorldService
{
lock (_sync)
{
if (_world.PlayerFaction is null && _world.Factions.Count == 0)
if (_world.Factions.Count == 0)
{
return null;
}
_playerFaction.EnsureDomain(_world);
return GetPlayerFactionSnapshotUnsafe();
var playerKey = GetCurrentPlayerKey();
var player = _playerFaction.TryGetDomain(_playerStateStore, playerKey)
?? _playerFaction.EnsureDomain(_world, _playerStateStore, playerKey);
return _playerFactionProjection.ToSnapshot(player);
}
}
@@ -137,7 +164,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.CreateOrganization(_world, request);
_playerFaction.CreateOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -146,7 +173,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.DeleteOrganization(_world, organizationId);
_playerFaction.DeleteOrganization(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -155,7 +182,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpdateOrganizationMembership(_world, organizationId, request);
_playerFaction.UpdateOrganizationMembership(_world, _playerStateStore, GetCurrentPlayerKey(), organizationId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -164,7 +191,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertDirective(_world, directiveId, request);
_playerFaction.UpsertDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -173,7 +200,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.DeleteDirective(_world, directiveId);
_playerFaction.DeleteDirective(_world, _playerStateStore, GetCurrentPlayerKey(), directiveId);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -182,7 +209,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertPolicy(_world, policyId, request);
_playerFaction.UpsertPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), policyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -191,7 +218,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertAutomationPolicy(_world, automationPolicyId, request);
_playerFaction.UpsertAutomationPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), automationPolicyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -200,7 +227,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertReinforcementPolicy(_world, reinforcementPolicyId, request);
_playerFaction.UpsertReinforcementPolicy(_world, _playerStateStore, GetCurrentPlayerKey(), reinforcementPolicyId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -209,7 +236,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertProductionProgram(_world, productionProgramId, request);
_playerFaction.UpsertProductionProgram(_world, _playerStateStore, GetCurrentPlayerKey(), productionProgramId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -218,7 +245,7 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpsertAssignment(_world, assetId, request);
_playerFaction.UpsertAssignment(_world, _playerStateStore, GetCurrentPlayerKey(), assetId, request);
return GetPlayerFactionSnapshotUnsafe();
}
}
@@ -227,11 +254,118 @@ public sealed class WorldService
{
lock (_sync)
{
_playerFaction.UpdateStrategicIntent(_world, request);
_playerFaction.UpdateStrategicIntent(_world, _playerStateStore, GetCurrentPlayerKey(), request);
return GetPlayerFactionSnapshotUnsafe();
}
}
public FactionSnapshot CreateFaction(string factionId)
{
lock (_sync)
{
if (_world.Factions.Any(candidate => string.Equals(candidate.Id, factionId, StringComparison.Ordinal)))
{
throw new InvalidOperationException($"Faction '{factionId}' already exists in the current world.");
}
var faction = _worldSeedingService.CreateFaction(factionId);
_world.Factions.Add(faction);
var policy = _worldSeedingService.CreatePolicies([faction]).Single();
_world.Policies.Add(policy);
var factionCommander = CreateFactionCommander(faction);
_world.Commanders.Add(factionCommander);
faction.CommanderIds.Add(factionCommander.Id);
new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("create-faction", $"Created faction {factionId}", "faction", factionId);
return _engine.BuildSnapshot(_world, _sequence).Factions.First(candidate => candidate.Id == factionId);
}
}
public ShipSnapshot SpawnShip(SpawnShipCommandRequest request)
{
lock (_sync)
{
var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world.");
var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world.");
var definition = ResolveShipDefinition(request, faction.Id);
var shipId = $"ship-{faction.Id}-{definition.Id}-{Guid.NewGuid():N}".ToLowerInvariant();
var spawnPosition = ResolveSpawnPosition(system.Definition.Id);
var homeStation = _world.Stations.FirstOrDefault(candidate =>
string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal)
&& string.Equals(candidate.SystemId, system.Definition.Id, StringComparison.Ordinal));
var defaultBehavior = CreateSpawnBehavior(request, definition, system.Definition.Id, homeStation);
var ship = new ShipRuntime
{
Id = shipId,
SystemId = system.Definition.Id,
Definition = definition,
FactionId = faction.Id,
Position = spawnPosition,
TargetPosition = spawnPosition,
SpatialState = SpatialBuilder.CreateInitialShipSpatialState(system.Definition.Id, spawnPosition, _world.Celestials),
DefaultBehavior = defaultBehavior,
Skills = ShipBootstrapPolicy.CreateSkills(definition),
Health = definition.Hull,
};
_world.Ships.Add(ship);
EnsureShipCommander(faction, ship);
new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("spawn-ship", $"Spawned ship {ship.Id}", "ship", ship.Id);
return GetShipSnapshotUnsafe(ship.Id)
?? throw new InvalidOperationException($"Ship '{ship.Id}' could not be projected.");
}
}
public StationSnapshot SpawnStation(SpawnStationCommandRequest request)
{
lock (_sync)
{
var faction = _world.Factions.FirstOrDefault(candidate => string.Equals(candidate.Id, request.FactionId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"Faction '{request.FactionId}' does not exist in the current world.");
var system = _world.Systems.FirstOrDefault(candidate => string.Equals(candidate.Definition.Id, request.SystemId, StringComparison.Ordinal))
?? throw new InvalidOperationException($"System '{request.SystemId}' does not exist in the current world.");
var objective = StationSimulationService.NormalizeStationObjective(request.Objective);
var label = string.IsNullOrWhiteSpace(request.Label)
? $"{faction.Label} {ToTitleCaseToken(objective)} {CountFactionStationsInSystem(faction.Id, system.Definition.Id) + 1}"
: request.Label.Trim();
var stationId = $"station-{faction.Id}-{objective}-{Guid.NewGuid():N}".ToLowerInvariant();
var position = ResolveStationSpawnPosition(system.Definition.Id);
var station = new StationRuntime
{
Id = stationId,
SystemId = system.Definition.Id,
Label = label,
Color = faction.Color,
Objective = objective,
Position = position,
FactionId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Health = 600f,
MaxHealth = 600f,
};
foreach (var moduleId in BuildStarterStationModules(faction.Id, objective))
{
AddStationModule(_world, station, moduleId);
}
station.PopulationCapacity = GetStationSupportedPopulation(_world.ModuleDefinitions, station);
station.WorkforceRequired = GetStationRequiredWorkforce(_world.ModuleDefinitions, station);
_world.Stations.Add(station);
new GeopoliticalSimulationService().Update(_world, 0f, []);
PublishSnapshotRefreshUnsafe("spawn-station", $"Spawned station {station.Id}", "station", station.Id);
return _engine.BuildSnapshot(_world, _sequence).Stations.First(candidate => candidate.Id == station.Id);
}
}
public ChannelReader<WorldDelta> Subscribe(ObserverScope scope, long afterSequence, CancellationToken cancellationToken)
{
var channel = Channel.CreateUnbounded<WorldDelta>(new UnboundedChannelOptions
@@ -318,6 +452,7 @@ public sealed class WorldService
private void ReplaceWorldUnsafe(SimulationWorld world, string eventKind, string eventMessage)
{
_world = world;
_playerStateStore.Clear();
_sequence += 1;
_history.Clear();
@@ -339,7 +474,6 @@ public sealed class WorldService
[],
[],
[],
null,
null);
_history.Enqueue(worldDelta);
@@ -349,11 +483,431 @@ public sealed class WorldService
}
}
private void PublishSnapshotRefreshUnsafe(
string eventKind,
string eventMessage,
string entityKind,
string entityId,
string scopeKind = "universe",
string? scopeEntityId = null)
{
_sequence += 1;
var eventTime = DateTimeOffset.UtcNow;
var worldDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
eventTime,
true,
[new SimulationEventRecord(entityKind, entityId, eventKind, eventMessage, eventTime, "world", scopeKind, scopeEntityId)],
[],
[],
[],
[],
[],
[],
[],
[],
[],
null);
_history.Enqueue(worldDelta);
while (_history.Count > DeltaHistoryLimit)
{
_history.Dequeue();
}
foreach (var subscriber in _subscribers.Values.ToList())
{
subscriber.Channel.Writer.TryWrite(FilterDeltaForScope(worldDelta, subscriber.Scope));
}
}
private ShipSnapshot? GetShipSnapshotUnsafe(string shipId) =>
_engine.BuildSnapshot(_world, _sequence).Ships.FirstOrDefault(ship => ship.Id == shipId);
private PlayerFactionSnapshot? GetPlayerFactionSnapshotUnsafe() =>
_engine.BuildSnapshot(_world, _sequence).PlayerFaction;
_playerFactionProjection.ToSnapshot(_playerFaction.TryGetDomain(_playerStateStore, GetCurrentPlayerKey()));
private string GetCurrentPlayerKey() => _playerIdentityResolver.GetRequiredPlayerId().ToString("N");
private bool CanCurrentActorAccessGm() => _playerIdentityResolver.CanAccessGm();
private string GetCurrentActorSourceId() =>
_playerIdentityResolver.GetCurrentPlayerId()?.ToString("N") ?? "gm";
private void ValidateShipOrderRequestUnsafe(string shipId, ShipOrderCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId)
?? throw new InvalidOperationException($"Ship '{shipId}' was not found.");
if (!string.Equals(request.Kind, ShipOrderKinds.MineAndDeliver, StringComparison.Ordinal))
{
return;
}
if (!IsMiningShip(ship.Definition))
{
throw new InvalidOperationException($"{ship.Definition.Name} cannot accept Mine Resource because it does not have mining capability.");
}
if (string.IsNullOrWhiteSpace(request.ItemId))
{
throw new InvalidOperationException("Mine Resource requires a ware.");
}
if (!_world.ItemDefinitions.TryGetValue(request.ItemId, out var itemDefinition))
{
throw new InvalidOperationException($"Mine Resource references unknown ware '{request.ItemId}'.");
}
if (itemDefinition.CargoKind is null)
{
throw new InvalidOperationException($"Mine Resource ware '{request.ItemId}' is not mineable.");
}
if (!ship.Definition.SupportsCargoKind(itemDefinition.CargoKind.Value))
{
throw new InvalidOperationException($"{ship.Definition.Name} cannot mine '{request.ItemId}' because it cannot store '{itemDefinition.CargoKind.Value.ToDataValue()}'.");
}
}
private ShipRuntime? EnqueueGmShipOrderUnsafe(string shipId, ShipOrderCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
if (ship.OrderQueue.Count >= 8)
{
throw new InvalidOperationException("Order queue is full.");
}
ship.OrderQueue.Add(new ShipOrderRuntime
{
Id = $"order-{ship.Id}-{Guid.NewGuid():N}",
Kind = request.Kind,
SourceKind = ShipOrderSourceKind.Player,
SourceId = GetCurrentActorSourceId(),
Priority = request.Priority,
InterruptCurrentPlan = request.InterruptCurrentPlan,
Label = request.Label,
TargetEntityId = request.TargetEntityId,
TargetSystemId = request.TargetSystemId,
TargetPosition = request.TargetPosition is null ? null : new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z),
SourceStationId = request.SourceStationId,
DestinationStationId = request.DestinationStationId,
ItemId = request.ItemId,
NodeId = request.NodeId,
ConstructionSiteId = request.ConstructionSiteId,
ModuleId = request.ModuleId,
WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? 0f),
Radius = MathF.Max(0f, request.Radius ?? 0f),
MaxSystemRange = request.MaxSystemRange,
KnownStationsOnly = request.KnownStationsOnly ?? false,
});
ship.ControlSourceKind = "gm-order";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = request.Label ?? request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-enqueued";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private ShipRuntime? RemoveGmShipOrderUnsafe(string shipId, string orderId)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
ship.OrderQueue.RemoveAll(order => order.Id == orderId);
ship.ControlSourceKind = ship.OrderQueue.Any(order => order.SourceKind == ShipOrderSourceKind.Player)
? "gm-order"
: "gm-manual";
ship.ControlSourceId = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Id)
.FirstOrDefault();
ship.ControlReason = ship.OrderQueue
.Where(order => order.SourceKind == ShipOrderSourceKind.Player)
.OrderByDescending(order => order.Priority)
.ThenBy(order => order.CreatedAtUtc)
.Select(order => order.Label ?? order.Kind)
.FirstOrDefault()
?? "manual-gm-control";
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-order-removed";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private ShipRuntime? ConfigureGmShipBehaviorUnsafe(string shipId, ShipDefaultBehaviorCommandRequest request)
{
var ship = _world.Ships.FirstOrDefault(candidate => candidate.Id == shipId);
if (ship is null)
{
return null;
}
ship.DefaultBehavior.Kind = request.Kind;
ship.DefaultBehavior.HomeSystemId = request.HomeSystemId ?? ship.SystemId;
ship.DefaultBehavior.HomeStationId = request.HomeStationId;
ship.DefaultBehavior.AreaSystemId = request.AreaSystemId;
ship.DefaultBehavior.TargetEntityId = request.TargetEntityId;
ship.DefaultBehavior.ItemId = request.ItemId;
ship.DefaultBehavior.PreferredNodeId = request.PreferredNodeId;
ship.DefaultBehavior.PreferredConstructionSiteId = request.PreferredConstructionSiteId;
ship.DefaultBehavior.PreferredModuleId = request.PreferredModuleId;
ship.DefaultBehavior.TargetPosition = request.TargetPosition is null
? null
: new Vector3(request.TargetPosition.X, request.TargetPosition.Y, request.TargetPosition.Z);
ship.DefaultBehavior.WaitSeconds = MathF.Max(0f, request.WaitSeconds ?? ship.DefaultBehavior.WaitSeconds);
ship.DefaultBehavior.Radius = MathF.Max(0f, request.Radius ?? ship.DefaultBehavior.Radius);
ship.DefaultBehavior.MaxSystemRange = Math.Max(0, request.MaxSystemRange ?? ship.DefaultBehavior.MaxSystemRange);
ship.DefaultBehavior.KnownStationsOnly = request.KnownStationsOnly ?? ship.DefaultBehavior.KnownStationsOnly;
ship.DefaultBehavior.PatrolPoints =
(request.PatrolPoints ?? [])
.Select(point => new Vector3(point.X, point.Y, point.Z))
.ToList();
ship.DefaultBehavior.PatrolIndex = 0;
ship.DefaultBehavior.RepeatOrders =
(request.RepeatOrders ?? [])
.Select(template => new ShipOrderTemplateRuntime
{
Kind = template.Kind,
Label = template.Label,
TargetEntityId = template.TargetEntityId,
TargetSystemId = template.TargetSystemId,
TargetPosition = template.TargetPosition is null ? null : new Vector3(template.TargetPosition.X, template.TargetPosition.Y, template.TargetPosition.Z),
SourceStationId = template.SourceStationId,
DestinationStationId = template.DestinationStationId,
ItemId = template.ItemId,
NodeId = template.NodeId,
ConstructionSiteId = template.ConstructionSiteId,
ModuleId = template.ModuleId,
WaitSeconds = template.WaitSeconds ?? 0f,
Radius = template.Radius ?? 0f,
MaxSystemRange = template.MaxSystemRange,
KnownStationsOnly = template.KnownStationsOnly ?? false,
})
.ToList();
ship.DefaultBehavior.RepeatIndex = 0;
ship.ControlSourceKind = "gm-manual";
ship.ControlSourceId = GetCurrentActorSourceId();
ship.ControlReason = request.Kind;
ship.NeedsReplan = true;
ship.LastReplanReason = "gm-behavior-updated";
ship.LastDeltaSignature = string.Empty;
return ship;
}
private CommanderRuntime CreateFactionCommander(FactionRuntime faction) => new()
{
Id = $"commander-faction-{faction.Id}",
Kind = CommanderKind.Faction,
FactionId = faction.Id,
ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-control",
};
private void EnsureShipCommander(FactionRuntime faction, ShipRuntime ship)
{
var factionCommander = _world.Commanders.FirstOrDefault(candidate =>
string.Equals(candidate.Kind, CommanderKind.Faction, StringComparison.Ordinal)
&& string.Equals(candidate.FactionId, faction.Id, StringComparison.Ordinal));
if (factionCommander is null)
{
return;
}
var commander = new CommanderRuntime
{
Id = $"commander-ship-{ship.Id}",
Kind = CommanderKind.Ship,
FactionId = faction.Id,
ParentCommanderId = factionCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = factionCommander.PolicySetId,
Doctrine = "ship-control",
Skills = new CommanderSkillProfileRuntime
{
Leadership = Math.Clamp((ship.Skills.Navigation + ship.Skills.Combat + 1) / 2, 2, 5),
Coordination = Math.Clamp((ship.Skills.Trade + ship.Skills.Mining + 1) / 2, 2, 5),
Strategy = Math.Clamp((ship.Skills.Combat + ship.Skills.Construction + 1) / 2, 2, 5),
},
};
ship.CommanderId = commander.Id;
ship.PolicySetId = factionCommander.PolicySetId;
factionCommander.SubordinateCommanderIds.Add(commander.Id);
faction.CommanderIds.Add(commander.Id);
_world.Commanders.Add(commander);
}
private ShipDefinition ResolveShipDefinition(SpawnShipCommandRequest request, string factionId)
{
if (!string.IsNullOrWhiteSpace(request.ShipId))
{
return _staticData.ShipDefinitions.TryGetValue(request.ShipId, out var explicitDefinition)
? explicitDefinition
: throw new InvalidOperationException($"Ship '{request.ShipId}' is not defined in static data.");
}
return _staticData.ShipDefinitions.Values
.Where(IsMiningShip)
.OrderBy(definition => !definition.Owners.Contains(factionId, StringComparer.Ordinal))
.ThenBy(definition => !definition.SupportsCargoKind(StorageKind.Solid))
.ThenBy(definition => definition.Size != "small")
.ThenBy(definition => definition.Id, StringComparer.Ordinal)
.FirstOrDefault()
?? throw new InvalidOperationException("No mining ship definition is available in static data.");
}
private Vector3 ResolveSpawnPosition(string systemId)
{
var shipsInSystem = _world.Ships.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal));
var angle = shipsInSystem * 0.73f;
return new Vector3(60f + (shipsInSystem * 12f), 0f, MathF.Sin(angle) * 34f);
}
private Vector3 ResolveStationSpawnPosition(string systemId)
{
var stationsInSystem = _world.Stations.Count(candidate => string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal));
var angle = stationsInSystem * 0.91f;
var radius = 160f + (stationsInSystem * 42f);
return new Vector3(MathF.Cos(angle) * radius, 0f, MathF.Sin(angle) * radius);
}
private IReadOnlyList<string> BuildStarterStationModules(string factionId, string objective)
{
var modules = new List<string>();
EnsureStationModule(modules, StarterStationLayoutResolver.ResolveDockModuleId(factionId, _staticData.ModuleDefinitions));
var powerModuleId = StarterStationLayoutResolver.ResolvePowerModuleId(factionId, _staticData.ModuleDefinitions);
EnsureStationModule(modules, powerModuleId);
var defaultContainerStorageModuleId = StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
powerModuleId,
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)
{
EnsureStationModule(modules, defaultContainerStorageModuleId);
}
var objectiveModuleId = StarterStationLayoutResolver.ResolveObjectiveModuleId(objective, factionId, _staticData.ModuleDefinitions);
if (!string.IsNullOrWhiteSpace(objectiveModuleId))
{
EnsureStationModule(modules, objectiveModuleId);
foreach (var storageModuleId in StarterStationLayoutResolver.ResolveRequiredStorageModuleIds(
objectiveModuleId,
factionId,
_staticData.ModuleDefinitions,
_staticData.ItemDefinitions))
{
EnsureStationModule(modules, storageModuleId);
}
}
return modules;
}
private static void EnsureStationModule(List<string> modules, string moduleId)
{
if (!modules.Contains(moduleId, StringComparer.Ordinal))
{
modules.Add(moduleId);
}
}
private int CountFactionStationsInSystem(string factionId, string systemId) =>
_world.Stations.Count(candidate =>
string.Equals(candidate.FactionId, factionId, StringComparison.Ordinal)
&& string.Equals(candidate.SystemId, systemId, StringComparison.Ordinal));
private static string ToTitleCaseToken(string value) =>
string.Join(" ",
value
.Split(['-', '_', ' '], StringSplitOptions.RemoveEmptyEntries)
.Select(part => part.Length == 0 ? part : char.ToUpperInvariant(part[0]) + part[1..]));
private static DefaultBehaviorRuntime CreateSpawnBehavior(
SpawnShipCommandRequest request,
ShipDefinition definition,
string systemId,
StationRuntime? homeStation)
{
var requestedBehavior = request.BehaviorKind?.Trim();
if (!string.IsNullOrWhiteSpace(requestedBehavior))
{
return new DefaultBehaviorRuntime
{
Kind = requestedBehavior,
HomeSystemId = systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
ItemId = string.Equals(requestedBehavior, LocalAutoMine, StringComparison.Ordinal) ? "ore" : null,
};
}
if (IsMiningShip(definition) && homeStation is not null)
{
return new DefaultBehaviorRuntime
{
Kind = LocalAutoMine,
HomeSystemId = systemId,
HomeStationId = homeStation.Id,
AreaSystemId = systemId,
};
}
if (IsMiningShip(definition))
{
return new DefaultBehaviorRuntime
{
Kind = LocalAutoMine,
HomeSystemId = systemId,
HomeStationId = null,
AreaSystemId = systemId,
ItemId = "ore",
};
}
return new DefaultBehaviorRuntime
{
Kind = HoldPosition,
HomeSystemId = systemId,
HomeStationId = homeStation?.Id,
AreaSystemId = systemId,
WaitSeconds = 4f,
Radius = 24f,
};
}
private static bool HasMeaningfulDelta(WorldDelta delta) =>
delta.RequiresSnapshotRefresh
@@ -367,7 +921,6 @@ public sealed class WorldService
|| delta.Policies.Count > 0
|| delta.Ships.Count > 0
|| delta.Factions.Count > 0
|| delta.PlayerFaction is not null
|| delta.Geopolitics is not null;
private void Unsubscribe(Guid subscriberId)
@@ -415,7 +968,6 @@ public sealed class WorldService
Policies = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Policies : [],
Ships = delta.Ships.Where((ship) => systemFilter is null || ship.SystemId == systemFilter).ToList(),
Factions = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Factions : [],
PlayerFaction = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.PlayerFaction : null,
Geopolitics = string.Equals(scope.ScopeKind, "universe", StringComparison.OrdinalIgnoreCase) ? delta.Geopolitics : null,
Scope = scope,
};