feat: production chain

This commit is contained in:
2026-03-15 22:46:47 -04:00
parent 651556c916
commit 5ba1287f85
65 changed files with 3718 additions and 687 deletions

View File

@@ -30,6 +30,7 @@ public sealed record ResourceNodeSnapshot(
string Id, string Id,
string SystemId, string SystemId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? AnchorNodeId,
string SourceKind, string SourceKind,
float OreRemaining, float OreRemaining,
float MaxOre, float MaxOre,
@@ -39,6 +40,7 @@ public sealed record ResourceNodeDelta(
string Id, string Id,
string SystemId, string SystemId,
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string? AnchorNodeId,
string SourceKind, string SourceKind,
float OreRemaining, float OreRemaining,
float MaxOre, float MaxOre,

View File

@@ -15,8 +15,13 @@ public sealed record StationSnapshot(
string? AnchorNodeId, string? AnchorNodeId,
string Color, string Color,
int DockedShips, int DockedShips,
IReadOnlyList<string> DockedShipIds,
int DockingPads, int DockingPads,
float FuelStored,
float FuelCapacity,
float EnergyStored, float EnergyStored,
float EnergyCapacity,
IReadOnlyList<StationActionProgressSnapshot> CurrentProcesses,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
string FactionId, string FactionId,
string? CommanderId, string? CommanderId,
@@ -39,8 +44,13 @@ public sealed record StationDelta(
string? AnchorNodeId, string? AnchorNodeId,
string Color, string Color,
int DockedShips, int DockedShips,
IReadOnlyList<string> DockedShipIds,
int DockingPads, int DockingPads,
float FuelStored,
float FuelCapacity,
float EnergyStored, float EnergyStored,
float EnergyCapacity,
IReadOnlyList<StationActionProgressSnapshot> CurrentProcesses,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
string FactionId, string FactionId,
string? CommanderId, string? CommanderId,
@@ -52,6 +62,11 @@ public sealed record StationDelta(
IReadOnlyList<string> InstalledModules, IReadOnlyList<string> InstalledModules,
IReadOnlyList<string> MarketOrderIds); IReadOnlyList<string> MarketOrderIds);
public sealed record StationActionProgressSnapshot(
string Lane,
string Label,
float Progress);
public sealed record ClaimSnapshot( public sealed record ClaimSnapshot(
string Id, string Id,
string FactionId, string FactionId,

View File

@@ -19,12 +19,14 @@ public sealed record ShipSnapshot(
string? CommanderId, string? CommanderId,
string? PolicySetId, string? PolicySetId,
float CargoCapacity, float CargoCapacity,
string? CargoItemId,
float WorkerPopulation, float WorkerPopulation,
float EnergyStored, float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
string FactionId, string FactionId,
float Health, float Health,
IReadOnlyList<string> History, IReadOnlyList<string> History,
ShipActionProgressSnapshot? CurrentAction,
ShipSpatialStateSnapshot SpatialState); ShipSpatialStateSnapshot SpatialState);
public sealed record ShipDelta( public sealed record ShipDelta(
@@ -46,14 +48,20 @@ public sealed record ShipDelta(
string? CommanderId, string? CommanderId,
string? PolicySetId, string? PolicySetId,
float CargoCapacity, float CargoCapacity,
string? CargoItemId,
float WorkerPopulation, float WorkerPopulation,
float EnergyStored, float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
string FactionId, string FactionId,
float Health, float Health,
IReadOnlyList<string> History, IReadOnlyList<string> History,
ShipActionProgressSnapshot? CurrentAction,
ShipSpatialStateSnapshot SpatialState); ShipSpatialStateSnapshot SpatialState);
public sealed record ShipActionProgressSnapshot(
string Label,
float Progress);
public sealed record ShipSpatialStateSnapshot( public sealed record ShipSpatialStateSnapshot(
string SpaceLayer, string SpaceLayer,
string CurrentSystemId, string CurrentSystemId,

View File

@@ -5,6 +5,8 @@ public sealed record WorldSnapshot(
int Seed, int Seed,
long Sequence, long Sequence,
int TickIntervalMs, int TickIntervalMs,
double OrbitalTimeSeconds,
OrbitalSimulationSnapshot OrbitalSimulation,
DateTimeOffset GeneratedAtUtc, DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems, IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<SpatialNodeSnapshot> SpatialNodes, IReadOnlyList<SpatialNodeSnapshot> SpatialNodes,
@@ -21,6 +23,8 @@ public sealed record WorldSnapshot(
public sealed record WorldDelta( public sealed record WorldDelta(
long Sequence, long Sequence,
int TickIntervalMs, int TickIntervalMs,
double OrbitalTimeSeconds,
OrbitalSimulationSnapshot OrbitalSimulation,
DateTimeOffset GeneratedAtUtc, DateTimeOffset GeneratedAtUtc,
bool RequiresSnapshotRefresh, bool RequiresSnapshotRefresh,
IReadOnlyList<SimulationEventRecord> Events, IReadOnlyList<SimulationEventRecord> Events,
@@ -51,3 +55,6 @@ public sealed record ObserverScope(
string ScopeKind, string ScopeKind,
string? SystemId = null, string? SystemId = null,
string? BubbleId = null); string? BubbleId = null);
public sealed record OrbitalSimulationSnapshot(
double SimulatedSecondsPerRealSecond);

View File

@@ -11,7 +11,6 @@ public sealed class BalanceDefinition
public float UndockingDuration { get; set; } public float UndockingDuration { get; set; }
public float UndockDistance { get; set; } public float UndockDistance { get; set; }
public EnergyBalanceDefinition Energy { get; set; } = new(); public EnergyBalanceDefinition Energy { get; set; } = new();
public FuelBalanceDefinition Fuel { get; set; } = new();
} }
public sealed class EnergyBalanceDefinition public sealed class EnergyBalanceDefinition
@@ -23,11 +22,6 @@ public sealed class EnergyBalanceDefinition
public float StationSolarCharge { get; set; } public float StationSolarCharge { get; set; }
} }
public sealed class FuelBalanceDefinition
{
public float WarpDrain { get; set; }
}
public sealed class SolarSystemDefinition public sealed class SolarSystemDefinition
{ {
public required string Id { get; set; } public required string Id { get; set; }
@@ -57,6 +51,9 @@ public sealed class ResourceNodeDefinition
public string SourceKind { get; set; } = "asteroid-belt"; public string SourceKind { get; set; } = "asteroid-belt";
public float Angle { get; set; } public float Angle { get; set; }
public float RadiusOffset { get; set; } public float RadiusOffset { get; set; }
public float InclinationDegrees { get; set; }
public int? AnchorPlanetIndex { get; set; }
public int? AnchorMoonIndex { get; set; }
public float OreAmount { get; set; } public float OreAmount { get; set; }
public required string ItemId { get; set; } public required string ItemId { get; set; }
public int ShardCount { get; set; } public int ShardCount { get; set; }
@@ -99,6 +96,7 @@ public sealed class RecipeDefinition
public List<string> RequiredModules { get; set; } = []; public List<string> RequiredModules { get; set; } = [];
public List<RecipeInputDefinition> Inputs { get; set; } = []; public List<RecipeInputDefinition> Inputs { get; set; } = [];
public List<RecipeOutputDefinition> Outputs { get; set; } = []; public List<RecipeOutputDefinition> Outputs { get; set; } = [];
public string? ShipOutputId { get; set; }
} }
public sealed class PlanetDefinition public sealed class PlanetDefinition

View File

@@ -16,6 +16,8 @@ builder.Services.AddCors((options) =>
.AllowAnyOrigin(); .AllowAnyOrigin();
}); });
}); });
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
builder.Services.AddSingleton<WorldService>(); builder.Services.AddSingleton<WorldService>();
builder.Services.AddHostedService<SimulationHostedService>(); builder.Services.AddHostedService<SimulationHostedService>();

View File

@@ -20,6 +20,7 @@ internal sealed class ShipBehaviorStateMachine
new PatrolShipBehaviorState(), new PatrolShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"), new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"),
new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"), new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"),
new EnergySupplyShipBehaviorState(),
new ConstructStationShipBehaviorState(), new ConstructStationShipBehaviorState(),
}; };

View File

@@ -8,7 +8,7 @@ internal sealed class IdleShipBehaviorState : IShipBehaviorState
{ {
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "idle", Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold, Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending, Status = WorkStatus.Pending,
}; };
@@ -30,7 +30,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
ship.DefaultBehavior.Kind = "idle"; ship.DefaultBehavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "idle", Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold, Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending, Status = WorkStatus.Pending,
}; };
@@ -39,7 +39,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "travel", Kind = ControllerTaskKind.Travel,
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex], TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId, TargetSystemId = ship.SystemId,
Threshold = 18f, Threshold = 18f,
@@ -82,6 +82,10 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
case ("extract", "cargo-full"): case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station"; ship.DefaultBehavior.Phase = "travel-to-station";
break; break;
case ("extract", "node-depleted"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
case ("travel-to-station", "arrived"): case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock"; ship.DefaultBehavior.Phase = "dock";
break; break;
@@ -114,10 +118,7 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
switch (ship.DefaultBehavior.Phase, controllerEvent) switch (ship.DefaultBehavior.Phase, controllerEvent)
{ {
case ("travel-to-station", "arrived"): case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock"; ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "deliver-to-site";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship) ? "refuel" : "deliver-to-site";
break; break;
case ("refuel", "refueled"): case ("refuel", "refueled"):
ship.DefaultBehavior.Phase = "deliver-to-site"; ship.DefaultBehavior.Phase = "deliver-to-site";
@@ -133,3 +134,37 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
} }
} }
} }
internal sealed class EnergySupplyShipBehaviorState : IShipBehaviorState
{
public string Kind => "auto-supply-energy";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanEnergySupply(ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-source", "arrived"):
case ("travel-to-destination", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "unload" : "load";
break;
case ("load", "loaded"):
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock";
break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock";
break;
case ("refuel", "refueled"):
ship.DefaultBehavior.Phase = "undock";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "travel-to-destination" : "travel-to-source";
break;
}
}
}

View File

@@ -12,7 +12,7 @@ public sealed class ShipRuntime
public required Vector3 TargetPosition { get; set; } public required Vector3 TargetPosition { get; set; }
public required ShipSpatialStateRuntime SpatialState { get; set; } public required ShipSpatialStateRuntime SpatialState { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero; public Vector3 Velocity { get; set; } = Vector3.Zero;
public string State { get; set; } = "idle"; public ShipState State { get; set; } = ShipState.Idle;
public ShipOrderRuntime? Order { get; set; } public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; } public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; } public required ControllerTaskRuntime ControllerTask { get; set; }
@@ -25,6 +25,8 @@ public sealed class ShipRuntime
public string? CommanderId { get; set; } public string? CommanderId { get; set; }
public string? PolicySetId { get; set; } public string? PolicySetId { get; set; }
public float Health { get; set; } public float Health { get; set; }
public string? TrackedActionKey { get; set; }
public float TrackedActionTotal { get; set; }
public List<string> History { get; } = []; public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty; public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty;
@@ -53,7 +55,7 @@ public sealed class DefaultBehaviorRuntime
public sealed class ControllerTaskRuntime public sealed class ControllerTaskRuntime
{ {
public required string Kind { get; set; } public required ControllerTaskKind Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending; public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? CommanderId { get; set; } public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; } public string? TargetEntityId { get; set; }

View File

@@ -24,6 +24,53 @@ public enum OrderStatus
Completed, Completed,
} }
public enum ShipState
{
Idle,
Arriving,
CapacitorStarved,
LocalFlight,
SpoolingWarp,
Warping,
SpoolingFtl,
Ftl,
CargoFull,
MiningApproach,
Mining,
NodeDepleted,
AwaitingDock,
DockingApproach,
Docking,
Docked,
Transferring,
Loading,
Unloading,
Refueling,
WaitingMaterials,
ConstructionBlocked,
Constructing,
DeliveringConstruction,
Blocked,
Undocking,
}
public enum ControllerTaskKind
{
Idle,
Travel,
Extract,
Dock,
Load,
Unload,
Refuel,
DeliverConstruction,
BuildConstructionSite,
LoadWorkers,
UnloadWorkers,
ConstructModule,
Undock,
}
public static class SpaceLayerKinds public static class SpaceLayerKinds
{ {
public const string UniverseSpace = "universe-space"; public const string UniverseSpace = "universe-space";
@@ -145,4 +192,53 @@ public static class SimulationEnumMappings
OrderStatus.Completed => "completed", OrderStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null), _ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
}; };
public static string ToContractValue(this ShipState state) => state switch
{
ShipState.Idle => "idle",
ShipState.Arriving => "arriving",
ShipState.CapacitorStarved => "capacitor-starved",
ShipState.LocalFlight => "local-flight",
ShipState.SpoolingWarp => "spooling-warp",
ShipState.Warping => "warping",
ShipState.SpoolingFtl => "spooling-ftl",
ShipState.Ftl => "ftl",
ShipState.CargoFull => "cargo-full",
ShipState.MiningApproach => "mining-approach",
ShipState.Mining => "mining",
ShipState.NodeDepleted => "node-depleted",
ShipState.AwaitingDock => "awaiting-dock",
ShipState.DockingApproach => "docking-approach",
ShipState.Docking => "docking",
ShipState.Docked => "docked",
ShipState.Transferring => "transferring",
ShipState.Loading => "loading",
ShipState.Unloading => "unloading",
ShipState.Refueling => "refueling",
ShipState.WaitingMaterials => "waiting-materials",
ShipState.ConstructionBlocked => "construction-blocked",
ShipState.Constructing => "constructing",
ShipState.DeliveringConstruction => "delivering-construction",
ShipState.Blocked => "blocked",
ShipState.Undocking => "undocking",
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
};
public static string ToContractValue(this ControllerTaskKind kind) => kind switch
{
ControllerTaskKind.Idle => "idle",
ControllerTaskKind.Travel => "travel",
ControllerTaskKind.Extract => "extract",
ControllerTaskKind.Dock => "dock",
ControllerTaskKind.Load => "load",
ControllerTaskKind.Unload => "unload",
ControllerTaskKind.Refuel => "refuel",
ControllerTaskKind.DeliverConstruction => "deliver-construction",
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
ControllerTaskKind.LoadWorkers => "load-workers",
ControllerTaskKind.UnloadWorkers => "unload-workers",
ControllerTaskKind.ConstructModule => "construct-module",
ControllerTaskKind.Undock => "undock",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
} }

View File

@@ -24,5 +24,6 @@ public sealed class SimulationWorld
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; } public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public required Dictionary<string, RecipeDefinition> Recipes { get; init; } public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
public int TickIntervalMs { get; init; } = 200; public int TickIntervalMs { get; init; } = 200;
public double OrbitalTimeSeconds { get; set; }
public DateTimeOffset GeneratedAtUtc { get; set; } public DateTimeOffset GeneratedAtUtc { get; set; }
} }

View File

@@ -12,9 +12,13 @@ public sealed class ResourceNodeRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string SystemId { get; init; } public required string SystemId { get; init; }
public required Vector3 Position { get; init; } public required Vector3 Position { get; set; }
public required string SourceKind { get; init; } public required string SourceKind { get; init; }
public required string ItemId { get; init; } public required string ItemId { get; init; }
public string? AnchorNodeId { get; set; }
public float OrbitRadius { get; init; }
public float OrbitPhase { get; init; }
public float OrbitInclination { get; init; }
public float OreRemaining { get; set; } public float OreRemaining { get; set; }
public float MaxOre { get; init; } public float MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty;

View File

@@ -14,17 +14,18 @@ public sealed class StationRuntime
public string? AnchorNodeId { get; set; } public string? AnchorNodeId { get; set; }
public string? CommanderId { get; set; } public string? CommanderId { get; set; }
public string? PolicySetId { get; set; } public string? PolicySetId { get; set; }
public HashSet<string> InstalledModules { get; } = new(StringComparer.Ordinal); public List<string> InstalledModules { get; } = [];
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal); public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> ProductionLaneTimers { get; } = new(StringComparer.Ordinal);
public Dictionary<int, string> DockingPadAssignments { get; } = new(); public Dictionary<int, string> DockingPadAssignments { get; } = new();
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal); public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float EnergyStored { get; set; } public float EnergyStored { get; set; }
public float ProcessTimer { get; set; }
public float Population { get; set; } public float Population { get; set; }
public float PopulationCapacity { get; set; } public float PopulationCapacity { get; set; }
public float WorkforceRequired { get; set; } public float WorkforceRequired { get; set; }
public float WorkforceEffectiveRatio { get; set; } = 0.1f; public float WorkforceEffectiveRatio { get; set; } = 0.1f;
public float PopulationGrowthProgress { get; set; } public float PopulationGrowthProgress { get; set; }
public float ShipProductionProgressSeconds { get; set; }
public HashSet<string> DockedShipIds { get; } = []; public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; } public ModuleConstructionRuntime? ActiveConstruction { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty;

View File

@@ -0,0 +1,6 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class OrbitalSimulationOptions
{
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
}

View File

@@ -4,13 +4,18 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader public sealed partial class ScenarioLoader
{ {
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) private const string SolSystemId = "sol";
private const string DevelopmentCompanionSystemId = "helios";
private static List<SolarSystemDefinition> InjectSpecialSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
bool includeSolSystem)
{ {
var systems = authoredSystems var systems = authoredSystems
.Select(CloneSystemDefinition) .Select(CloneSystemDefinition)
.ToList(); .ToList();
if (systems.All((system) => system.Id != "sol")) if (includeSolSystem && systems.All((system) => system.Id != "sol"))
{ {
systems.Add(CreateSolSystem()); systems.Add(CreateSolSystem());
} }
@@ -18,13 +23,25 @@ public sealed partial class ScenarioLoader
return systems; return systems;
} }
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems) private static List<SolarSystemDefinition> ExpandSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
int targetSystemCount)
{ {
var systems = authoredSystems var systems = authoredSystems
.Select(CloneSystemDefinition) .Select(CloneSystemDefinition)
.ToList(); .ToList();
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0) if (targetSystemCount <= 0)
{
return [];
}
if (systems.Count > targetSystemCount)
{
return TrimSystemsToTarget(systems, targetSystemCount);
}
if (systems.Count >= targetSystemCount || authoredSystems.Count == 0)
{ {
return systems; return systems;
} }
@@ -32,9 +49,11 @@ public sealed partial class ScenarioLoader
var existingIds = systems var existingIds = systems
.Select((system) => system.Id) .Select((system) => system.Id)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count); var generatedPositions = BuildGalaxyPositions(
authoredSystems.Select((system) => ToVector(system.Position)).ToList(),
targetSystemCount - systems.Count);
for (var index = systems.Count; index < TargetSystemCount; index += 1) for (var index = systems.Count; index < targetSystemCount; index += 1)
{ {
var template = authoredSystems[index % authoredSystems.Count]; var template = authoredSystems[index % authoredSystems.Count];
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length]; var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
@@ -50,6 +69,63 @@ public sealed partial class ScenarioLoader
return systems; 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);
}
}
AddById(SolSystemId);
AddById(DevelopmentCompanionSystemId);
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[] { 2600f, 24f, -420f },
new[] { -2400f, -36f, 560f },
new[] { 520f, 42f, 2480f },
};
for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1)
{
systems[index].Position = compactPositions[index];
}
}
private static SolarSystemDefinition CreateGeneratedSystem( private static SolarSystemDefinition CreateGeneratedSystem(
SolarSystemDefinition template, SolarSystemDefinition template,
string label, string label,
@@ -66,6 +142,9 @@ public sealed partial class ScenarioLoader
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
Angle = node.Angle, Angle = node.Angle,
RadiusOffset = node.RadiusOffset, RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount, OreAmount = node.OreAmount,
ItemId = node.ItemId, ItemId = node.ItemId,
ShardCount = node.ShardCount, ShardCount = node.ShardCount,
@@ -118,8 +197,12 @@ public sealed partial class ScenarioLoader
ResourceNodes = definition.ResourceNodes ResourceNodes = definition.ResourceNodes
.Select((node) => new ResourceNodeDefinition .Select((node) => new ResourceNodeDefinition
{ {
SourceKind = node.SourceKind,
Angle = node.Angle, Angle = node.Angle,
RadiusOffset = node.RadiusOffset, RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount, OreAmount = node.OreAmount,
ItemId = node.ItemId, ItemId = node.ItemId,
ShardCount = node.ShardCount, ShardCount = node.ShardCount,
@@ -161,6 +244,9 @@ public sealed partial class ScenarioLoader
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
Angle = node.Angle, Angle = node.Angle,
RadiusOffset = node.RadiusOffset, RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount, OreAmount = node.OreAmount,
ItemId = node.ItemId, ItemId = node.ItemId,
ShardCount = node.ShardCount, ShardCount = node.ShardCount,
@@ -239,9 +325,8 @@ public sealed partial class ScenarioLoader
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets) private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{ {
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
var nodeCount = 4 + (generatedIndex % 4); var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 2800f + ((generatedIndex % 5) * 320f); var oreAmount = 1000f;
for (var index = 0; index < nodeCount; index += 1) for (var index = 0; index < nodeCount; index += 1)
{ {
@@ -249,7 +334,9 @@ public sealed partial class ScenarioLoader
{ {
SourceKind = "asteroid-belt", SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f), Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f), RadiusOffset = 120f + Jitter(generatedIndex, 200 + index, 36f),
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f),
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets),
OreAmount = oreAmount, OreAmount = oreAmount,
ItemId = "ore", ItemId = "ore",
ShardCount = 6 + (index % 4), ShardCount = 6 + (index % 4),
@@ -269,15 +356,27 @@ public sealed partial class ScenarioLoader
yield break; yield break;
} }
var gasAnchorIndex = 0;
for (var index = 0; index < planets.Count; index += 1)
{
if (ReferenceEquals(planets[index], gasAnchor))
{
gasAnchorIndex = index;
break;
}
}
var nodeCount = 2 + (generatedIndex % 3); var nodeCount = 2 + (generatedIndex % 3);
var gasAmount = 2200f + ((generatedIndex % 4) * 260f); var gasAmount = 1000f;
for (var index = 0; index < nodeCount; index += 1) for (var index = 0; index < nodeCount; index += 1)
{ {
yield return new ResourceNodeDefinition yield return new ResourceNodeDefinition
{ {
SourceKind = "gas-cloud", SourceKind = "gas-cloud",
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f), Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f), RadiusOffset = 170f + Jitter(generatedIndex, 260 + index, 44f),
InclinationDegrees = Jitter(generatedIndex, 320 + index, 10f),
AnchorPlanetIndex = gasAnchorIndex,
OreAmount = gasAmount, OreAmount = gasAmount,
ItemId = "gas", ItemId = "gas",
ShardCount = 10 + index, ShardCount = 10 + index,
@@ -285,19 +384,29 @@ public sealed partial class ScenarioLoader
} }
} }
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex) private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)
{ {
var gap = planets if (planets.Count == 0)
.Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius))
.OrderByDescending((entry) => entry.Gap)
.FirstOrDefault();
if (gap.Gap > 1f)
{ {
return gap.LeftOrbitRadius + (gap.Gap * 0.52f); return 0;
} }
return 420f + ((generatedIndex % 5) * 60f); var gasGiantIndex = -1;
for (var index = 0; index < planets.Count; index += 1)
{
if (planets[index].PlanetType is "gas-giant" or "ice-giant")
{
gasGiantIndex = index;
break;
}
}
if (gasGiantIndex > 0)
{
return gasGiantIndex - 1;
}
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
} }
private static List<PlanetDefinition> BuildGeneratedPlanets( private static List<PlanetDefinition> BuildGeneratedPlanets(
@@ -424,6 +533,15 @@ public sealed partial class ScenarioLoader
private static SolarSystemDefinition CreateSolSystem() private static SolarSystemDefinition CreateSolSystem()
{ {
var mercuryOrbitAu = 0.3871f;
var venusOrbitAu = 0.7233f;
var earthOrbitAu = 1.000f;
var marsOrbitAu = 1.5237f;
var jupiterOrbitAu = 5.203f;
var saturnOrbitAu = 9.582f;
var uranusOrbitAu = 19.201f;
var neptuneOrbitAu = 30.047f;
return new SolarSystemDefinition return new SolarSystemDefinition
{ {
Id = "sol", Id = "sol",
@@ -438,30 +556,30 @@ public sealed partial class ScenarioLoader
AsteroidField = new AsteroidFieldDefinition AsteroidField = new AsteroidFieldDefinition
{ {
DecorationCount = 240, DecorationCount = 240,
RadiusOffset = 780f, RadiusOffset = ScaleSolOrbitRadiusFromAu(2.82f),
RadiusVariance = 180f, RadiusVariance = 180f,
HeightVariance = 22f, HeightVariance = 22f,
}, },
ResourceNodes = ResourceNodes =
[ [
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 126f, InclinationDegrees = 4f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 148f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 138f, InclinationDegrees = 8f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 }, new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 164f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 }, new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 210f, InclinationDegrees = 3f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 }, new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 228f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 }, new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, ItemId = "gas", ShardCount = 10 },
], ],
Planets = Planets =
[ [
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false), CreateSolPlanet("Mercury", "barren", "sphere", 0, mercuryOrbitAu, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false), CreateSolPlanet("Venus", "desert", "sphere", 0, venusOrbitAu, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false), CreateSolPlanet("Earth", "terrestrial", "sphere", 1, earthOrbitAu, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false), CreateSolPlanet("Mars", "desert", "sphere", 2, marsOrbitAu, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true), CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, jupiterOrbitAu, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true), CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, saturnOrbitAu, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true), CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, uranusOrbitAu, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true) CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, neptuneOrbitAu, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
], ],
}; };
} }
@@ -471,8 +589,7 @@ public sealed partial class ScenarioLoader
string planetType, string planetType,
string shape, string shape,
int moonCount, int moonCount,
float orbitRadius, float orbitRadiusAu,
float orbitSpeed,
float orbitEccentricity, float orbitEccentricity,
float orbitInclination, float orbitInclination,
float ascendingNode, float ascendingNode,
@@ -488,8 +605,8 @@ public sealed partial class ScenarioLoader
PlanetType = planetType, PlanetType = planetType,
Shape = shape, Shape = shape,
MoonCount = moonCount, MoonCount = moonCount,
OrbitRadius = orbitRadius, OrbitRadius = ScaleSolOrbitRadiusFromAu(orbitRadiusAu),
OrbitSpeed = orbitSpeed, OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu),
OrbitEccentricity = orbitEccentricity, OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination, OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = ascendingNode, OrbitLongitudeOfAscendingNode = ascendingNode,
@@ -506,4 +623,13 @@ public sealed partial class ScenarioLoader
HasRing = hasRing, HasRing = hasRing,
}; };
} }
private static float ScaleSolOrbitRadiusFromAu(float orbitRadiusAu) =>
MathF.Round(500f * MathF.Pow(orbitRadiusAu, 0.70f));
private static float ComputeSolOrbitSpeed(float orbitRadiusAu)
{
const float earthAngularSpeed = 0.11f;
return earthAngularSpeed / MathF.Sqrt(orbitRadiusAu * orbitRadiusAu * orbitRadiusAu);
}
} }

View File

@@ -21,7 +21,12 @@ public sealed partial class ScenarioLoader
factionIds.Add(DefaultFactionId); factionIds.Add(DefaultFactionId);
} }
return factionIds.Select(CreateFaction).ToList(); factionIds.Add(UnclaimedFactionId);
return factionIds
.Distinct(StringComparer.Ordinal)
.Select(CreateFaction)
.ToList();
} }
private static FactionRuntime CreateFaction(string factionId) private static FactionRuntime CreateFaction(string factionId)
@@ -35,6 +40,13 @@ public sealed partial class ScenarioLoader
Color = "#7ed4ff", Color = "#7ed4ff",
Credits = MinimumFactionCredits, Credits = MinimumFactionCredits,
}, },
UnclaimedFactionId => new FactionRuntime
{
Id = factionId,
Label = "Unclaimed",
Color = "#7f8794",
Credits = 0f,
},
_ => new FactionRuntime _ => new FactionRuntime
{ {
Id = factionId, Id = factionId,
@@ -89,30 +101,32 @@ public sealed partial class ScenarioLoader
IReadOnlyCollection<NodeRuntime> nodes, IReadOnlyCollection<NodeRuntime> nodes,
DateTimeOffset nowUtc) DateTimeOffset nowUtc)
{ {
var claims = new List<ClaimRuntime>(stations.Count); var stationsByAnchorNodeId = stations
foreach (var station in stations) .Where((station) => station.AnchorNodeId is not null)
.ToDictionary((station) => station.AnchorNodeId!, StringComparer.Ordinal);
var claims = new List<ClaimRuntime>();
foreach (var node in nodes.Where((candidate) => candidate.Kind == SpatialNodeKind.LagrangePoint))
{ {
if (station.AnchorNodeId is null) var owningFactionId = stationsByAnchorNodeId.TryGetValue(node.Id, out var station)
{ ? station.FactionId
continue; : UnclaimedFactionId;
} var activatesAtUtc = owningFactionId == UnclaimedFactionId
? nowUtc
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId); : nowUtc.AddSeconds(8);
if (anchorNode is null) var state = owningFactionId == UnclaimedFactionId
{ ? ClaimStateKinds.Active
continue; : ClaimStateKinds.Activating;
}
claims.Add(new ClaimRuntime claims.Add(new ClaimRuntime
{ {
Id = $"claim-{station.Id}", Id = $"claim-{node.Id}",
FactionId = station.FactionId, FactionId = owningFactionId,
SystemId = station.SystemId, SystemId = node.SystemId,
NodeId = anchorNode.Id, NodeId = node.Id,
BubbleId = anchorNode.BubbleId, BubbleId = node.BubbleId,
PlacedAtUtc = nowUtc, PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8), ActivatesAtUtc = activatesAtUtc,
State = ClaimStateKinds.Activating, State = state,
Health = 100f, Health = 100f,
}); });
} }
@@ -138,8 +152,13 @@ public sealed partial class ScenarioLoader
} }
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId); var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
var claim = claims.FirstOrDefault((candidate) => candidate.Id == $"claim-{station.Id}"); if (anchorNode is null)
if (anchorNode is null || claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe)) {
continue;
}
var claim = claims.FirstOrDefault((candidate) => candidate.NodeId == anchorNode.Id);
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
{ {
continue; continue;
} }
@@ -192,9 +211,20 @@ public sealed partial class ScenarioLoader
StationRuntime station, StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes) IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{ {
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
{ {
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) ("gas-tank", 1),
("fuel-processor", 1),
("refinery-stack", 1),
("container-bay", 1),
("fabricator-array", 2),
("component-factory", 1),
("ship-factory", 1),
("solar-array", 2),
("dock-bay-small", 2),
})
{
if (CountModules(station.InstalledModules, moduleId) < targetCount
&& moduleRecipes.ContainsKey(moduleId)) && moduleRecipes.ContainsKey(moduleId))
{ {
return moduleId; return moduleId;
@@ -220,6 +250,11 @@ public sealed partial class ScenarioLoader
var policies = new List<PolicySetRuntime>(factions.Count); var policies = new List<PolicySetRuntime>(factions.Count);
foreach (var faction in factions) foreach (var faction in factions)
{ {
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
{
continue;
}
var policyId = $"policy-{faction.Id}"; var policyId = $"policy-{faction.Id}";
faction.DefaultPolicySetId = policyId; faction.DefaultPolicySetId = policyId;
policies.Add(new PolicySetRuntime policies.Add(new PolicySetRuntime
@@ -244,6 +279,11 @@ public sealed partial class ScenarioLoader
foreach (var faction in factions) foreach (var faction in factions)
{ {
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
{
continue;
}
var commander = new CommanderRuntime var commander = new CommanderRuntime
{ {
Id = $"commander-faction-{faction.Id}", Id = $"commander-faction-{faction.Id}",
@@ -251,9 +291,16 @@ public sealed partial class ScenarioLoader
FactionId = faction.Id, FactionId = faction.Id,
ControlledEntityId = faction.Id, ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId, PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-default", Doctrine = "strategic-expansionist",
}; };
commander.Goals.Add("control-all-systems");
commander.Goals.Add("control-five-systems-fast");
commander.Goals.Add("expand-industrial-base");
commander.Goals.Add("grow-war-fleet");
commander.Goals.Add("deter-pirate-harassment");
commander.Goals.Add("contest-rival-expansion");
commanders.Add(commander); commanders.Add(commander);
factionCommanders[faction.Id] = commander; factionCommanders[faction.Id] = commander;
faction.CommanderIds.Add(commander.Id); faction.CommanderIds.Add(commander.Id);
@@ -334,7 +381,7 @@ public sealed partial class ScenarioLoader
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes, IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery) StationRuntime? refinery)
{ {
if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null) if (string.Equals(definition.Role, "construction", StringComparison.Ordinal) && refinery is not null)
{ {
return new DefaultBehaviorRuntime return new DefaultBehaviorRuntime
{ {
@@ -345,24 +392,23 @@ public sealed partial class ScenarioLoader
} }
if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null) if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null)
{
return CreateResourceHarvestBehavior("auto-harvest-gas", scenario.MiningDefaults.NodeSystemId, refinery.Id);
}
if (string.Equals(definition.Role, "transport", StringComparison.Ordinal) && refinery is not null)
{ {
return new DefaultBehaviorRuntime return new DefaultBehaviorRuntime
{ {
Kind = "auto-harvest-gas", Kind = "auto-supply-energy",
StationId = refinery.Id, StationId = refinery.Id,
Phase = "travel-to-node", Phase = "travel-to-source",
}; };
} }
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null) if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
{ {
return new DefaultBehaviorRuntime return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
{
Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
StationId = refinery.Id,
Phase = "travel-to-node",
};
} }
if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route)) if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route))
@@ -381,6 +427,14 @@ public sealed partial class ScenarioLoader
}; };
} }
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
{
Kind = kind,
AreaSystemId = areaSystemId,
StationId = stationId,
Phase = "travel-to-node",
};
private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new() private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
{ {
Kind = behavior.Kind, Kind = behavior.Kind,
@@ -402,7 +456,7 @@ public sealed partial class ScenarioLoader
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new() private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
{ {
Kind = task.Kind, Kind = task.Kind.ToContractValue(),
Status = task.Status, Status = task.Status,
TargetEntityId = task.TargetEntityId, TargetEntityId = task.TargetEntityId,
TargetNodeId = targetNodeId ?? task.TargetNodeId, TargetNodeId = targetNodeId ?? task.TargetNodeId,

View File

@@ -35,7 +35,7 @@ public sealed partial class ScenarioLoader
parentNodeId: starNode.Id); parentNodeId: starNode.Id);
var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal); var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
{ {
var lagrangeNode = AddSpatialNode( var lagrangeNode = AddSpatialNode(
nodes, nodes,
@@ -111,22 +111,41 @@ public sealed partial class ScenarioLoader
return node; return node;
} }
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex) private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
float orbitRadius,
float planetSize,
int planetIndex)
{ {
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X); var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f)); var offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
var triangularAngle = MathF.PI / 3f; var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset))); yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset))); yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadius)); yield return new LagrangePointPlacement("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
yield return new LagrangePointPlacement( yield return new LagrangePointPlacement(
"L4", "L4",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle)))); Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
yield return new LagrangePointPlacement( yield return new LagrangePointPlacement(
"L5", "L5",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle)))); Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
{
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
var sizeScale = (planetSize * 1.9f) + 10f;
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
} }
private static StationPlacement ResolveStationPlacement( private static StationPlacement ResolveStationPlacement(
@@ -172,6 +191,39 @@ public sealed partial class ScenarioLoader
_ => "L1", _ => "L1",
}; };
private static NodeRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
{
if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
{
return null;
}
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
{
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
return graph.Nodes.FirstOrDefault((node) => node.Id == moonNodeId);
}
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
return graph.Nodes.FirstOrDefault((node) => node.Id == planetNodeId);
}
private static Vector3 ComputeResourceNodePosition(NodeRuntime? anchorNode, ResourceNodeDefinition definition, float yPlane)
{
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.18f, 28f);
var offset = new Vector3(
MathF.Cos(definition.Angle) * definition.RadiusOffset,
verticalOffset,
MathF.Sin(definition.Angle) * definition.RadiusOffset);
if (anchorNode is null)
{
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
}
return Add(anchorNode.Position, offset);
}
private static Vector3 ComputePlanetPosition(PlanetDefinition planet) private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
{ {
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch); var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);

View File

@@ -6,7 +6,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader public sealed partial class ScenarioLoader
{ {
private const string DefaultFactionId = "sol-dominion"; private const string DefaultFactionId = "sol-dominion";
private const int TargetSystemCount = 160; private const string UnclaimedFactionId = "unclaimed";
private const int WorldSeed = 1; private const int WorldSeed = 1;
private const float MinimumFactionCredits = 0f; private const float MinimumFactionCredits = 0f;
private const float MinimumRefineryOre = 0f; private const float MinimumRefineryOre = 0f;
@@ -76,20 +76,27 @@ public sealed partial class ScenarioLoader
]; ];
private readonly string _dataRoot; private readonly string _dataRoot;
private readonly WorldGenerationOptions _worldGeneration;
private readonly JsonSerializerOptions _jsonOptions = new() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNameCaseInsensitive = true, PropertyNameCaseInsensitive = true,
}; };
public ScenarioLoader(string contentRootPath) public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
{ {
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data")); _dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
_worldGeneration = worldGeneration ?? new WorldGenerationOptions();
} }
public SimulationWorld Load() public SimulationWorld Load()
{ {
var systems = ExpandSystems(InjectSpecialSystems(Read<List<SolarSystemDefinition>>("systems.json"))); var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
var scenario = Read<ScenarioDefinition>("scenario.json"); var systems = ExpandSystems(
InjectSpecialSystems(authoredSystems, _worldGeneration.IncludeSolSystem),
_worldGeneration.TargetSystemCount);
var scenario = NormalizeScenarioToAvailableSystems(
Read<ScenarioDefinition>("scenario.json"),
systems.Select((system) => system.Id).ToList());
var ships = Read<List<ShipDefinition>>("ships.json"); var ships = Read<List<ShipDefinition>>("ships.json");
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json"); var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
var items = Read<List<ItemDefinition>>("items.json"); var items = Read<List<ItemDefinition>>("items.json");
@@ -127,18 +134,21 @@ public sealed partial class ScenarioLoader
foreach (var system in systemRuntimes) foreach (var system in systemRuntimes)
{ {
var systemGraph = systemGraphs[system.Definition.Id];
foreach (var node in system.Definition.ResourceNodes) foreach (var node in system.Definition.ResourceNodes)
{ {
var anchorNode = ResolveResourceNodeAnchor(systemGraph, node);
var resourceNode = new ResourceNodeRuntime var resourceNode = new ResourceNodeRuntime
{ {
Id = $"node-{++nodeIdCounter}", Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Position = new Vector3( Position = ComputeResourceNodePosition(anchorNode, node, balance.YPlane),
MathF.Cos(node.Angle) * node.RadiusOffset,
balance.YPlane,
MathF.Sin(node.Angle) * node.RadiusOffset),
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
ItemId = node.ItemId, ItemId = node.ItemId,
AnchorNodeId = anchorNode?.Id,
OrbitRadius = node.RadiusOffset,
OrbitPhase = node.Angle,
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
OreRemaining = node.OreAmount, OreRemaining = node.OreAmount,
MaxOre = node.OreAmount, MaxOre = node.OreAmount,
}; };
@@ -152,6 +162,7 @@ public sealed partial class ScenarioLoader
Kind = SpatialNodeKind.ResourceSite, Kind = SpatialNodeKind.ResourceSite,
Position = resourceNode.Position, Position = resourceNode.Position,
BubbleId = bubbleId, BubbleId = bubbleId,
ParentNodeId = anchorNode?.Id,
}); });
localBubbles.Add(new LocalBubbleRuntime localBubbles.Add(new LocalBubbleRuntime
{ {
@@ -230,10 +241,15 @@ public sealed partial class ScenarioLoader
station.SystemId == scenario.MiningDefaults.RefinerySystemId) station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank")); ?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank"));
var patrolRoutes = scenario.PatrolRoutes.ToDictionary( var patrolRoutes = scenario.PatrolRoutes
(route) => route.SystemId, .GroupBy((route) => route.SystemId, StringComparer.Ordinal)
(route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], point)).ToList(), .ToDictionary(
StringComparer.Ordinal); (group) => group.Key,
(group) => group
.SelectMany((route) => route.Points)
.Select((point) => NormalizeScenarioPoint(systemsById[group.Key], point))
.ToList(),
StringComparer.Ordinal);
var shipsRuntime = new List<ShipRuntime>(); var shipsRuntime = new List<ShipRuntime>();
var shipIdCounter = 0; var shipIdCounter = 0;
@@ -258,7 +274,7 @@ public sealed partial class ScenarioLoader
TargetPosition = position, TargetPosition = position,
SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes), SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes),
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery), DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending }, ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
Health = definition.MaxHealth, Health = definition.MaxHealth,
}); });
@@ -306,6 +322,7 @@ public sealed partial class ScenarioLoader
ItemDefinitions = itemDefinitions, ItemDefinitions = itemDefinitions,
ModuleRecipes = moduleRecipeDefinitions, ModuleRecipes = moduleRecipeDefinitions,
Recipes = recipeDefinitions, Recipes = recipeDefinitions,
OrbitalTimeSeconds = WorldSeed * 97d,
GeneratedAtUtc = DateTimeOffset.UtcNow, GeneratedAtUtc = DateTimeOffset.UtcNow,
}; };
} }
@@ -318,6 +335,60 @@ public sealed partial class ScenarioLoader
?? throw new InvalidOperationException($"Unable to read {fileName}."); ?? throw new InvalidOperationException($"Unable to read {fileName}.");
} }
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
ScenarioDefinition scenario,
IReadOnlyList<string> availableSystemIds)
{
if (availableSystemIds.Count == 0)
{
return scenario;
}
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
? "sol"
: availableSystemIds[0];
string ResolveSystemId(string systemId) =>
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
return new ScenarioDefinition
{
InitialStations = scenario.InitialStations
.Select((station) => new InitialStationDefinition
{
ConstructibleId = station.ConstructibleId,
SystemId = ResolveSystemId(station.SystemId),
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,
})
.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 Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)

View File

@@ -7,18 +7,19 @@ public sealed partial class SimulationEngine
var task = ship.ControllerTask; var task = ship.ControllerTask;
return task.Kind switch return task.Kind switch
{ {
"idle" => UpdateIdle(ship, world, deltaSeconds), ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
"travel" => UpdateTravel(ship, world, deltaSeconds), ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
"extract" => UpdateExtract(ship, world, deltaSeconds), ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
"dock" => UpdateDock(ship, world, deltaSeconds), ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
"unload" => UpdateUnload(ship, world, deltaSeconds), ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
"refuel" => UpdateRefuel(ship, world, deltaSeconds), ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
"deliver-construction" => UpdateDeliverConstruction(ship, world, deltaSeconds), ControllerTaskKind.Refuel => UpdateRefuel(ship, world, deltaSeconds),
"build-construction-site" => UpdateBuildConstructionSite(ship, world, deltaSeconds), ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
"load-workers" => UpdateLoadWorkers(ship, world, deltaSeconds), ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
"unload-workers" => UpdateUnloadWorkers(ship, world, deltaSeconds), ControllerTaskKind.LoadWorkers => UpdateLoadWorkers(ship, world, deltaSeconds),
"construct-module" => UpdateConstructModule(ship, world, deltaSeconds), ControllerTaskKind.UnloadWorkers => UpdateUnloadWorkers(ship, world, deltaSeconds),
"undock" => UpdateUndock(ship, world, deltaSeconds), ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
_ => UpdateIdle(ship, world, deltaSeconds), _ => UpdateIdle(ship, world, deltaSeconds),
}; };
} }
@@ -26,7 +27,7 @@ public sealed partial class SimulationEngine
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -36,7 +37,7 @@ public sealed partial class SimulationEngine
var task = ship.ControllerTask; var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null) if (task.TargetPosition is null || task.TargetSystemId is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -47,7 +48,9 @@ public sealed partial class SimulationEngine
if (ship.SystemId != task.TargetSystemId) if (ship.SystemId != task.TargetSystemId)
{ {
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode); var destinationEntryNode = ResolveSystemEntryNode(world, task.TargetSystemId);
var destinationEntryPosition = destinationEntryNode?.Position ?? Vector3.Zero;
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryNode);
} }
var currentNode = ResolveCurrentNode(world, ship); var currentNode = ResolveCurrentNode(world, ship);
@@ -95,6 +98,11 @@ public sealed partial class SimulationEngine
.FirstOrDefault(); .FirstOrDefault();
} }
private static NodeRuntime? ResolveSystemEntryNode(SimulationWorld world, string systemId) =>
world.SpatialNodes.FirstOrDefault(candidate =>
candidate.SystemId == systemId &&
candidate.Kind == SpatialNodeKind.Star);
private string UpdateLocalTravel( private string UpdateLocalTravel(
ShipRuntime ship, ShipRuntime ship,
SimulationWorld world, SimulationWorld world,
@@ -119,19 +127,19 @@ public sealed partial class SimulationEngine
ship.SystemId = targetSystemId; ship.SystemId = targetSystemId;
ship.SpatialState.CurrentNodeId = targetNode?.Id; ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.State = "arriving"; ship.State = ShipState.Arriving;
return "arrived"; return "arrived";
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "local-flight"; ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds); ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
return "none"; return "none";
} }
@@ -158,32 +166,32 @@ public sealed partial class SimulationEngine
ship.SpatialState.DestinationNodeId = targetNode.Id; ship.SpatialState.DestinationNodeId = targetNode.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f); var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != "warping") if (ship.State != ShipState.Warping)
{ {
if (ship.State != "spooling-warp") if (ship.State != ShipState.SpoolingWarp)
{ {
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "spooling-warp"; ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration)) if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{ {
return "none"; return "none";
} }
ship.State = "warping"; ship.State = ShipState.Warping;
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -220,32 +228,32 @@ public sealed partial class SimulationEngine
ship.SpatialState.CurrentBubbleId = null; ship.SpatialState.CurrentBubbleId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId; ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != "ftl") if (ship.State != ShipState.Ftl)
{ {
if (ship.State != "spooling-ftl") if (ship.State != ShipState.SpoolingFtl)
{ {
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "spooling-ftl"; ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime)) if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{ {
return "none"; return "none";
} }
ship.State = "ftl"; ship.State = ShipState.Ftl;
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -254,7 +262,7 @@ public sealed partial class SimulationEngine
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds); ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance)); transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 24f return ship.Position.DistanceTo(targetPosition) <= 24f
? CompleteTransitArrival(ship, targetSystemId, targetPosition, targetNode) ? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetNode)
: "none"; : "none";
} }
@@ -270,7 +278,23 @@ public sealed partial class SimulationEngine
ship.SpatialState.CurrentNodeId = targetNode?.Id; ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId; ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.SpatialState.DestinationNodeId = targetNode?.Id; ship.SpatialState.DestinationNodeId = targetNode?.Id;
ship.State = "arriving"; ship.State = ShipState.Arriving;
return "arrived"; return "arrived";
} }
private static string CompleteSystemEntryArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, NodeRuntime? targetNode)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.SpatialState.DestinationNodeId = targetNode?.Id;
ship.State = ShipState.Arriving;
return "none";
}
} }

View File

@@ -55,22 +55,57 @@ public sealed partial class SimulationEngine
return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f); return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f);
} }
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex) private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
{
var baseSpeed = node.SourceKind == "gas-cloud" ? 0.16f : 0.24f;
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180f, 0.45f));
}
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
{
var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node));
var orbit = new Vector3(
MathF.Cos(angle) * node.OrbitRadius,
0f,
MathF.Sin(angle) * node.OrbitRadius);
return RotateAroundX(orbit, node.OrbitInclination);
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
Vector3 planetPosition,
float orbitRadius,
float planetSize,
int planetIndex)
{ {
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f)); var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X); var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f)); var offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
var triangularAngle = MathF.PI / 3f; var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset))); yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset))); yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadius)); yield return new LagrangePointPlacement("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
yield return new LagrangePointPlacement( yield return new LagrangePointPlacement(
"L4", "L4",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle)))); Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
yield return new LagrangePointPlacement( yield return new LagrangePointPlacement(
"L5", "L5",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle)))); Add(
planetPosition,
Add(
Scale(radial, offset * MathF.Cos(triangularAngle)),
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
}
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
{
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
var sizeScale = (planetSize * 1.9f) + 10f;
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
} }
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback) private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
@@ -125,9 +160,9 @@ public sealed partial class SimulationEngine
} }
} }
private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc) private void UpdateOrbitalState(SimulationWorld world)
{ {
var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f); var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal); var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
foreach (var system in world.Systems) foreach (var system in world.Systems)
@@ -150,7 +185,7 @@ public sealed partial class SimulationEngine
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds); var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
planetNode.Position = planetPosition; planetNode.Position = planetPosition;
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex)) foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
{ {
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}"; var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode)) if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode))
@@ -186,6 +221,20 @@ public sealed partial class SimulationEngine
} }
} }
foreach (var node in world.Nodes)
{
if (node.AnchorNodeId is null || !spatialNodesById.TryGetValue(node.AnchorNodeId, out var anchorNode))
{
continue;
}
node.Position = Add(anchorNode.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
if (spatialNodesById.TryGetValue(node.Id, out var resourceNode))
{
resourceNode.Position = node.Position;
}
}
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null)) foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
{ {
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);

View File

@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine public sealed partial class SimulationEngine
{ {
private const float StationEnergyCellToEnergyRatio = 1f;
private static bool HasShipModules(ShipDefinition definition, params string[] modules) => private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
@@ -19,7 +21,7 @@ public sealed partial class SimulationEngine
foreach (var station in world.Stations) foreach (var station in world.Stations)
{ {
var previousEnergy = station.EnergyStored; var previousEnergy = station.EnergyStored;
GenerateStationEnergy(station, deltaSeconds); GenerateStationEnergy(station, world, deltaSeconds);
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f) if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f)
{ {
@@ -39,7 +41,7 @@ public sealed partial class SimulationEngine
} }
} }
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds) private static void GenerateStationEnergy(StationRuntime station, SimulationWorld world, float deltaSeconds)
{ {
var powerCores = CountModules(station.InstalledModules, "power-core"); var powerCores = CountModules(station.InstalledModules, "power-core");
var tanks = CountModules(station.InstalledModules, "liquid-tank"); var tanks = CountModules(station.InstalledModules, "liquid-tank");
@@ -53,6 +55,32 @@ public sealed partial class SimulationEngine
var energyCapacity = powerCores * StationEnergyPerPowerCore; var energyCapacity = powerCores * StationEnergyPerPowerCore;
var fuelStored = GetInventoryAmount(station.Inventory, "fuel"); var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored); var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
if (desiredEnergy <= 0.01f)
{
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
return;
}
var solarGenerated = MathF.Min(desiredEnergy, GetStationSolarGeneration(station, world) * deltaSeconds);
if (solarGenerated > 0.01f)
{
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + solarGenerated);
desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
}
if (desiredEnergy > 0.01f && fuelStored <= 0.01f)
{
var energyCells = GetInventoryAmount(station.Inventory, "energy-cell");
if (energyCells > 0.01f)
{
var consumedCells = MathF.Min(energyCells, desiredEnergy / StationEnergyCellToEnergyRatio);
RemoveInventory(station.Inventory, "energy-cell", consumedCells);
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + (consumedCells * StationEnergyCellToEnergyRatio));
desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
}
}
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f) if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
{ {
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity); station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
@@ -69,6 +97,37 @@ public sealed partial class SimulationEngine
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated); station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
} }
private static float GetStationFuelCapacity(StationRuntime station) =>
CountModules(station.InstalledModules, "liquid-tank") * StationFuelPerTank;
private static float GetStationEnergyCapacity(StationRuntime station) =>
CountModules(station.InstalledModules, "power-core") * StationEnergyPerPowerCore;
private static float GetStationSolarGeneration(StationRuntime station, SimulationWorld world) =>
world.Balance.Energy.StationSolarCharge * (1f + CountModules(station.InstalledModules, "solar-array"));
private static float GetStationStorageCapacity(StationRuntime station, string storageClass)
{
var baseCapacity = station.Definition.Storage.TryGetValue(storageClass, out var capacity)
? capacity
: 0f;
var extraBulkBays = Math.Max(0, CountModules(station.InstalledModules, "bulk-bay") - CountModules(station.Definition.Modules, "bulk-bay"));
var extraLiquidTanks = Math.Max(0, CountModules(station.InstalledModules, "liquid-tank") - CountModules(station.Definition.Modules, "liquid-tank"));
var extraGasTanks = Math.Max(0, CountModules(station.InstalledModules, "gas-tank") - CountModules(station.Definition.Modules, "gas-tank"));
var extraContainerBays = Math.Max(0, CountModules(station.InstalledModules, "container-bay") - CountModules(station.Definition.Modules, "container-bay"));
var moduleBonus = storageClass switch
{
"bulk-solid" => extraBulkBays * 1000f,
"bulk-liquid" => extraLiquidTanks * 500f,
"bulk-gas" => extraGasTanks * 500f,
"container" => extraContainerBays * 800f,
_ => 0f,
};
return baseCapacity + moduleBonus;
}
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
var reactors = CountModules(ship.Definition.Modules, "reactor-core"); var reactors = CountModules(ship.Definition.Modules, "reactor-core");
@@ -166,11 +225,265 @@ public sealed partial class SimulationEngine
_ => false, _ => false,
}; };
private static bool CanBuildClaimBeacon(ShipRuntime ship) =>
string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal);
private static float GetShipFuelCapacity(ShipRuntime ship) => private static float GetShipFuelCapacity(ShipRuntime ship) =>
CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor; CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor;
internal static bool NeedsRefuel(ShipRuntime ship) => private static float GetShipAvailableEnergyBudget(ShipRuntime ship) =>
GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f); ship.EnergyStored + (GetInventoryAmount(ship.Inventory, "fuel") * ShipFuelToEnergyRatio);
private static float GetShipFuelReserve(ShipRuntime ship, float plannedFuel)
{
var capacity = GetShipFuelCapacity(ship);
var reserveRatio = ship.Definition.CargoItemId == "gas" ? 0.4f : 0.3f;
var reserve = MathF.Max(16f, MathF.Max(capacity * 0.18f, plannedFuel * reserveRatio));
return MathF.Min(capacity, reserve);
}
private static float EstimateFuelForEnergyDemand(ShipRuntime ship, float energyDemand) =>
MathF.Max(0f, energyDemand - ship.EnergyStored) / ShipFuelToEnergyRatio;
private static float EstimateTimedEnergyUse(SimulationWorld world, float durationSeconds, float drainPerSecond) =>
MathF.Max(0f, durationSeconds) * drainPerSecond;
private static float EstimateTravelEnergy(
ShipRuntime ship,
SimulationWorld world,
Vector3 fromPosition,
string fromSystemId,
Vector3 toPosition,
string toSystemId)
{
if (!string.Equals(fromSystemId, toSystemId, StringComparison.Ordinal))
{
var destinationEntryNode = ResolveSystemEntryNode(world, toSystemId);
var destinationEntryPosition = destinationEntryNode?.Position ?? toPosition;
var ftlDistance = fromPosition.DistanceTo(destinationEntryPosition);
var ftlDuration = ftlDistance / MathF.Max(ship.Definition.FtlSpeed, 0.01f);
return EstimateTimedEnergyUse(world, ship.Definition.SpoolTime, world.Balance.Energy.IdleDrain)
+ EstimateTimedEnergyUse(world, ftlDuration, world.Balance.Energy.WarpDrain)
+ EstimateInSystemTravelEnergy(ship, world, destinationEntryPosition, toPosition);
}
return EstimateInSystemTravelEnergy(ship, world, fromPosition, toPosition);
}
private static float EstimateInSystemTravelEnergy(ShipRuntime ship, SimulationWorld world, Vector3 fromPosition, Vector3 toPosition)
{
var distance = fromPosition.DistanceTo(toPosition);
if (distance <= world.Balance.ArrivalThreshold)
{
return 0f;
}
if (distance <= 120f)
{
var localDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
return EstimateTimedEnergyUse(world, localDuration, world.Balance.Energy.MoveDrain);
}
var warpSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
var warpDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
return EstimateTimedEnergyUse(world, warpSpoolDuration, world.Balance.Energy.IdleDrain)
+ EstimateTimedEnergyUse(world, warpDuration, world.Balance.Energy.WarpDrain);
}
private static float EstimateDockingEnergy(SimulationWorld world) =>
EstimateTimedEnergyUse(world, world.Balance.DockingDuration, world.Balance.Energy.MoveDrain)
+ EstimateTimedEnergyUse(world, 6f, world.Balance.Energy.IdleDrain);
private static float EstimateUndockingEnergy(SimulationWorld world) =>
EstimateTimedEnergyUse(world, world.Balance.UndockingDuration, world.Balance.Energy.MoveDrain)
+ EstimateTimedEnergyUse(world, 4f, world.Balance.Energy.IdleDrain);
private static float EstimateExtractionEnergy(ShipRuntime ship, SimulationWorld world)
{
var remainingCargo = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
if (remainingCargo <= 0.01f)
{
return 0f;
}
var cycles = MathF.Ceiling(remainingCargo / MathF.Max(world.Balance.MiningRate, 0.01f));
return EstimateTimedEnergyUse(world, cycles * world.Balance.MiningCycleSeconds, world.Balance.Energy.MoveDrain)
+ EstimateTimedEnergyUse(world, cycles * 1.5f, world.Balance.Energy.IdleDrain);
}
private static float EstimateConstructionEnergy(ShipRuntime ship, SimulationWorld world, StationRuntime station)
{
var holdPosition = GetConstructionHoldPosition(station, ship.Id);
var travelEnergy = EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, holdPosition, station.SystemId);
var site = GetConstructionSiteForStation(world, station.Id);
if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
{
if (world.ModuleRecipes.TryGetValue(site.BlueprintId ?? string.Empty, out var siteRecipe))
{
return travelEnergy + EstimateTimedEnergyUse(world, siteRecipe.Duration, world.Balance.Energy.IdleDrain);
}
return travelEnergy;
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
if (moduleId is not null
&& world.ModuleRecipes.TryGetValue(moduleId, out var recipe)
&& CanStartModuleConstruction(station, recipe))
{
return travelEnergy + EstimateTimedEnergyUse(world, recipe.Duration, world.Balance.Energy.IdleDrain);
}
return travelEnergy;
}
private static float EstimateResourceHarvestEnergy(ShipRuntime ship, SimulationWorld world)
{
var cargoItemId = ship.Definition.CargoItemId;
if (cargoItemId is null)
{
return 0f;
}
var requiredModule = cargoItemId == "gas" ? "gas-extractor" : "mining-turret";
var behavior = ship.DefaultBehavior;
var refinery = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate =>
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
candidate.ItemId == cargoItemId &&
candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
{
return 0f;
}
var currentPosition = ship.Position;
var currentSystemId = ship.SystemId;
var energy = 0f;
var cargoAmount = GetShipCargoAmount(ship);
if (ship.DockedStationId == refinery.Id)
{
currentPosition = GetUndockTargetPosition(refinery, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
currentSystemId = refinery.SystemId;
energy += EstimateUndockingEnergy(world);
}
if (cargoAmount > 0.01f)
{
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId);
return energy + EstimateDockingEnergy(world);
}
var holdPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, holdPosition, node.SystemId);
energy += EstimateExtractionEnergy(ship, world);
energy += EstimateTravelEnergy(ship, world, holdPosition, node.SystemId, refinery.Position, refinery.SystemId);
energy += EstimateDockingEnergy(world);
return energy;
}
private static float EstimateResourceReturnEnergy(ShipRuntime ship, SimulationWorld world)
{
var cargoItemId = ship.Definition.CargoItemId;
if (cargoItemId is null)
{
return 0f;
}
var refinery = SelectBestBuyStation(world, ship, cargoItemId, ship.DefaultBehavior.StationId);
if (refinery is null)
{
return 0f;
}
var currentPosition = ship.Position;
var currentSystemId = ship.SystemId;
return EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId)
+ EstimateDockingEnergy(world);
}
private static float EstimateTransportEnergy(ShipRuntime ship, SimulationWorld world)
{
var cargoItemId = ship.Definition.CargoItemId;
if (cargoItemId is null)
{
return 0f;
}
var behavior = ship.DefaultBehavior;
var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId);
var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
if (source is null && destination is null)
{
return 0f;
}
var cargoAmount = GetShipCargoAmount(ship);
var currentPosition = ship.Position;
var currentSystemId = ship.SystemId;
if (ship.DockedStationId is not null)
{
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (dockedStation is not null)
{
currentPosition = GetUndockTargetPosition(dockedStation, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
currentSystemId = dockedStation.SystemId;
}
}
var targetStation = cargoAmount > 0.01f ? destination : source;
if (targetStation is null)
{
return ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f;
}
var energy = ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f;
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, targetStation.Position, targetStation.SystemId);
return energy + EstimateDockingEnergy(world);
}
private static float EstimateShipMissionEnergyDemand(ShipRuntime ship, SimulationWorld world) =>
ship.DefaultBehavior.Kind switch
{
"auto-mine" or "auto-harvest-gas" => EstimateResourceHarvestEnergy(ship, world),
"auto-supply-energy" => EstimateTransportEnergy(ship, world),
"construct-station" when ship.DefaultBehavior.StationId is not null
=> world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) is { } station
? EstimateConstructionEnergy(ship, world, station)
: 0f,
_ when ship.ControllerTask.TargetPosition is { } targetPosition && ship.ControllerTask.TargetSystemId is { } targetSystemId
=> EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, targetPosition, targetSystemId),
_ => 0f,
};
private static float GetShipRefuelTarget(ShipRuntime ship, SimulationWorld world)
{
var capacity = GetShipFuelCapacity(ship);
var missionFuel = EstimateFuelForEnergyDemand(ship, EstimateShipMissionEnergyDemand(ship, world));
var reserveFuel = GetShipFuelReserve(ship, missionFuel);
return MathF.Min(capacity, missionFuel + reserveFuel);
}
internal static bool NeedsRefuel(ShipRuntime ship, SimulationWorld world) =>
GetInventoryAmount(ship.Inventory, "fuel") + 0.01f < GetShipRefuelTarget(ship, world);
internal static bool NeedsEmergencyReturn(ShipRuntime ship, SimulationWorld world)
{
if (ship.DefaultBehavior.Kind is not "auto-mine" and not "auto-harvest-gas")
{
return false;
}
var returnEnergy = EstimateResourceReturnEnergy(ship, world);
var reserveFuel = GetShipFuelReserve(ship, EstimateFuelForEnergyDemand(ship, returnEnergy));
var requiredBudget = returnEnergy + (reserveFuel * ShipFuelToEnergyRatio);
return GetShipAvailableEnergyBudget(ship) + 0.01f < requiredBudget;
}
private static float ComputeWorkforceRatio(float population, float workforceRequired) private static float ComputeWorkforceRatio(float population, float workforceRequired)
{ {
@@ -206,7 +519,8 @@ public sealed partial class SimulationEngine
return 0f; return 0f;
} }
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity)) var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f)
{ {
return 0f; return 0f;
} }
@@ -232,6 +546,17 @@ public sealed partial class SimulationEngine
string.Equals(site.StationId, stationId, StringComparison.Ordinal) string.Equals(site.StationId, stationId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed); && site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
private static bool IsConstructionSiteReady(ConstructionSiteRuntime site) => private static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
site.RequiredItems.All(entry => GetInventoryAmount(site.DeliveredItems, entry.Key) + 0.001f >= entry.Value); {
if (site.StationId is not null
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
{
return GetInventoryAmount(station.Inventory, itemId);
}
return GetInventoryAmount(site.DeliveredItems, itemId);
}
private static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
} }

View File

@@ -13,6 +13,8 @@ public sealed partial class SimulationEngine
world.Seed, world.Seed,
sequence, sequence,
world.TickIntervalMs, world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc, world.GeneratedAtUtc,
world.Systems.Select(system => new SystemSnapshot( world.Systems.Select(system => new SystemSnapshot(
system.Definition.Id, system.Definition.Id,
@@ -59,11 +61,12 @@ public sealed partial class SimulationEngine
node.Id, node.Id,
node.SystemId, node.SystemId,
node.LocalPosition, node.LocalPosition,
node.AnchorNodeId,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
node.ItemId)).ToList(), node.ItemId)).ToList(),
world.Stations.Select(ToStationDelta).Select(station => new StationSnapshot( world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
station.Id, station.Id,
station.Label, station.Label,
station.Category, station.Category,
@@ -74,8 +77,13 @@ public sealed partial class SimulationEngine
station.AnchorNodeId, station.AnchorNodeId,
station.Color, station.Color,
station.DockedShips, station.DockedShips,
station.DockedShipIds,
station.DockingPads, station.DockingPads,
station.FuelStored,
station.FuelCapacity,
station.EnergyStored, station.EnergyStored,
station.EnergyCapacity,
station.CurrentProcesses,
station.Inventory, station.Inventory,
station.FactionId, station.FactionId,
station.CommanderId, station.CommanderId,
@@ -135,7 +143,7 @@ public sealed partial class SimulationEngine
policy.DockingAccessPolicy, policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy, policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy)).ToList(), policy.OperationalRangePolicy)).ToList(),
world.Ships.Select(ToShipDelta).Select(ship => new ShipSnapshot( world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
ship.Id, ship.Id,
ship.Label, ship.Label,
ship.Role, ship.Role,
@@ -154,12 +162,14 @@ public sealed partial class SimulationEngine
ship.CommanderId, ship.CommanderId,
ship.PolicySetId, ship.PolicySetId,
ship.CargoCapacity, ship.CargoCapacity,
ship.CargoItemId,
ship.WorkerPopulation, ship.WorkerPopulation,
ship.EnergyStored, ship.EnergyStored,
ship.Inventory, ship.Inventory,
ship.FactionId, ship.FactionId,
ship.Health, ship.Health,
ship.History, ship.History,
ship.CurrentAction,
ship.SpatialState)).ToList(), ship.SpatialState)).ToList(),
world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot( world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot(
faction.Id, faction.Id,
@@ -193,7 +203,7 @@ public sealed partial class SimulationEngine
foreach (var station in world.Stations) foreach (var station in world.Stations)
{ {
station.LastDeltaSignature = BuildStationSignature(station); station.LastDeltaSignature = BuildStationSignature(world, station);
} }
foreach (var claim in world.Claims) foreach (var claim in world.Claims)
@@ -218,7 +228,7 @@ public sealed partial class SimulationEngine
foreach (var ship in world.Ships) foreach (var ship in world.Ships)
{ {
ship.LastDeltaSignature = BuildShipSignature(ship); ship.LastDeltaSignature = BuildShipSignature(world, ship);
} }
foreach (var faction in world.Factions) foreach (var faction in world.Factions)
@@ -286,14 +296,14 @@ public sealed partial class SimulationEngine
var deltas = new List<StationDelta>(); var deltas = new List<StationDelta>();
foreach (var station in world.Stations) foreach (var station in world.Stations)
{ {
var signature = BuildStationSignature(station); var signature = BuildStationSignature(world, station);
if (signature == station.LastDeltaSignature) if (signature == station.LastDeltaSignature)
{ {
continue; continue;
} }
station.LastDeltaSignature = signature; station.LastDeltaSignature = signature;
deltas.Add(ToStationDelta(station)); deltas.Add(ToStationDelta(world, station));
} }
return deltas; return deltas;
@@ -371,19 +381,19 @@ public sealed partial class SimulationEngine
return deltas; return deltas;
} }
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world) private IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
{ {
var deltas = new List<ShipDelta>(); var deltas = new List<ShipDelta>();
foreach (var ship in world.Ships) foreach (var ship in world.Ships)
{ {
var signature = BuildShipSignature(ship); var signature = BuildShipSignature(world, ship);
if (signature == ship.LastDeltaSignature) if (signature == ship.LastDeltaSignature)
{ {
continue; continue;
} }
ship.LastDeltaSignature = signature; ship.LastDeltaSignature = signature;
deltas.Add(ToShipDelta(ship)); deltas.Add(ToShipDelta(world, ship));
} }
return deltas; return deltas;
@@ -407,7 +417,8 @@ public sealed partial class SimulationEngine
return deltas; return deltas;
} }
private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}"; private static string BuildNodeSignature(ResourceNodeRuntime node) =>
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.AnchorNodeId}|{node.OreRemaining:0.###}";
private static string BuildSpatialNodeSignature(NodeRuntime node) => private static string BuildSpatialNodeSignature(NodeRuntime node) =>
$"{node.SystemId}|{node.Kind.ToContractValue()}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.BubbleId}|{node.ParentNodeId}|{node.OccupyingStructureId}|{node.OrbitReferenceId}"; $"{node.SystemId}|{node.Kind.ToContractValue()}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.BubbleId}|{node.ParentNodeId}|{node.OccupyingStructureId}|{node.OrbitReferenceId}";
@@ -415,8 +426,34 @@ public sealed partial class SimulationEngine
private static string BuildLocalBubbleSignature(LocalBubbleRuntime bubble) => private static string BuildLocalBubbleSignature(LocalBubbleRuntime bubble) =>
$"{bubble.SystemId}|{bubble.NodeId}|{bubble.Radius:0.###}|{string.Join(",", bubble.OccupantShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantStationIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal))}"; $"{bubble.SystemId}|{bubble.NodeId}|{bubble.Radius:0.###}|{string.Join(",", bubble.OccupantShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantStationIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal))}";
private static string BuildStationSignature(StationRuntime station) => private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
$"{station.SystemId}|{station.NodeId}|{station.BubbleId}|{station.AnchorNodeId}|{station.CommanderId}|{station.PolicySetId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{station.Population:0.###}|{station.PopulationCapacity:0.###}|{station.WorkforceRequired:0.###}|{station.WorkforceEffectiveRatio:0.###}|{string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal))}|{string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}"; {
var processes = ToStationActionProgressSnapshots(world, station);
return string.Join("|",
station.SystemId,
station.NodeId ?? "none",
station.BubbleId ?? "none",
station.AnchorNodeId ?? "none",
station.CommanderId ?? "none",
station.PolicySetId ?? "none",
BuildInventorySignature(station.Inventory),
GetInventoryAmount(station.Inventory, "fuel").ToString("0.###"),
GetStationFuelCapacity(station).ToString("0.###"),
station.EnergyStored.ToString("0.###"),
GetStationEnergyCapacity(station).ToString("0.###"),
string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")),
string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)),
station.DockingPadAssignments.Count.ToString(),
station.Population.ToString("0.###"),
station.PopulationCapacity.ToString("0.###"),
station.WorkforceRequired.ToString("0.###"),
station.WorkforceEffectiveRatio.ToString("0.###"),
string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal)),
string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal)),
station.ActiveConstruction?.ModuleId ?? "none",
station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0",
string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}")));
}
private static string BuildClaimSignature(ClaimRuntime claim) => private static string BuildClaimSignature(ClaimRuntime claim) =>
$"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}"; $"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
@@ -430,7 +467,7 @@ public sealed partial class SimulationEngine
private static string BuildPolicySignature(PolicySetRuntime policy) => private static string BuildPolicySignature(PolicySetRuntime policy) =>
$"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}"; $"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}";
private static string BuildShipSignature(ShipRuntime ship) => private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) =>
string.Join("|", string.Join("|",
ship.SystemId, ship.SystemId,
ship.Position.X.ToString("0.###"), ship.Position.X.ToString("0.###"),
@@ -442,10 +479,10 @@ public sealed partial class SimulationEngine
ship.TargetPosition.X.ToString("0.###"), ship.TargetPosition.X.ToString("0.###"),
ship.TargetPosition.Y.ToString("0.###"), ship.TargetPosition.Y.ToString("0.###"),
ship.TargetPosition.Z.ToString("0.###"), ship.TargetPosition.Z.ToString("0.###"),
ship.State, ship.State.ToContractValue(),
ship.Order?.Kind ?? "none", ship.Order?.Kind ?? "none",
ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind, ship.ControllerTask.Kind.ToContractValue(),
ship.SpatialState.CurrentNodeId ?? "none", ship.SpatialState.CurrentNodeId ?? "none",
ship.SpatialState.CurrentBubbleId ?? "none", ship.SpatialState.CurrentBubbleId ?? "none",
ship.DockedStationId ?? "none", ship.DockedStationId ?? "none",
@@ -463,8 +500,14 @@ public sealed partial class SimulationEngine
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0", ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).ToString("0.###"), GetShipCargoAmount(ship).ToString("0.###"),
GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"), GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"),
ship.TrackedActionKey ?? "none",
ship.TrackedActionTotal.ToString("0.###"),
ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site
? GetRemainingConstructionDelivery(world, site).ToString("0.###")
: "0",
ship.EnergyStored.ToString("0.###"), ship.EnergyStored.ToString("0.###"),
ship.Health.ToString("0.###")); ship.Health.ToString("0.###"),
ship.ActionTimer.ToString("0.###"));
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) => private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",", string.Join(",",
@@ -480,6 +523,7 @@ public sealed partial class SimulationEngine
node.Id, node.Id,
node.SystemId, node.SystemId,
ToDto(node.Position), ToDto(node.Position),
node.AnchorNodeId,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
@@ -505,7 +549,7 @@ public sealed partial class SimulationEngine
bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(), bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList()); bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList());
private static StationDelta ToStationDelta(StationRuntime station) => new( private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new(
station.Id, station.Id,
station.Definition.Label, station.Definition.Label,
station.Definition.Category, station.Definition.Category,
@@ -516,8 +560,13 @@ public sealed partial class SimulationEngine
station.AnchorNodeId, station.AnchorNodeId,
station.Definition.Color, station.Definition.Color,
station.DockedShipIds.Count, station.DockedShipIds.Count,
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
GetDockingPadCount(station), GetDockingPadCount(station),
GetInventoryAmount(station.Inventory, "fuel"),
GetStationFuelCapacity(station),
station.EnergyStored, station.EnergyStored,
GetStationEnergyCapacity(station),
ToStationActionProgressSnapshots(world, station),
ToInventoryEntries(station.Inventory), ToInventoryEntries(station.Inventory),
station.FactionId, station.FactionId,
station.CommanderId, station.CommanderId,
@@ -529,6 +578,23 @@ public sealed partial class SimulationEngine
station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(), station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(),
station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList()); station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList());
private static IReadOnlyList<StationActionProgressSnapshot> ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) =>
GetStationProductionLanes(station)
.Select(laneKey =>
{
var recipe = SelectProductionRecipe(world, station, laneKey);
var timer = GetStationProductionTimer(station, laneKey);
return recipe is null || station.EnergyStored <= 0.01f || timer <= 0.01f
? null
: new StationActionProgressSnapshot(
laneKey,
recipe.Label,
Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f));
})
.Where(snapshot => snapshot is not null)
.Cast<StationActionProgressSnapshot>()
.ToList();
private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new( private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new(
claim.Id, claim.Id,
claim.FactionId, claim.FactionId,
@@ -582,7 +648,7 @@ public sealed partial class SimulationEngine
policy.ConstructionAccessPolicy, policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy); policy.OperationalRangePolicy);
private static ShipDelta ToShipDelta(ShipRuntime ship) => new( private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) => new(
ship.Id, ship.Id,
ship.Definition.Label, ship.Definition.Label,
ship.Definition.Role, ship.Definition.Role,
@@ -591,24 +657,75 @@ public sealed partial class SimulationEngine
ToDto(ship.Position), ToDto(ship.Position),
ToDto(ship.Velocity), ToDto(ship.Velocity),
ToDto(ship.TargetPosition), ToDto(ship.TargetPosition),
ship.State, ship.State.ToContractValue(),
ship.Order?.Kind, ship.Order?.Kind,
ship.DefaultBehavior.Kind, ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind, ship.ControllerTask.Kind.ToContractValue(),
ship.SpatialState.CurrentNodeId, ship.SpatialState.CurrentNodeId,
ship.SpatialState.CurrentBubbleId, ship.SpatialState.CurrentBubbleId,
ship.DockedStationId, ship.DockedStationId,
ship.CommanderId, ship.CommanderId,
ship.PolicySetId, ship.PolicySetId,
ship.Definition.CargoCapacity, ship.Definition.CargoCapacity,
ship.Definition.CargoItemId,
ship.WorkerPopulation, ship.WorkerPopulation,
ship.EnergyStored, ship.EnergyStored,
ToInventoryEntries(ship.Inventory), ToInventoryEntries(ship.Inventory),
ship.FactionId, ship.FactionId,
ship.Health, ship.Health,
ship.History.ToList(), ship.History.ToList(),
ToShipActionProgressSnapshot(world, ship),
ToShipSpatialStateSnapshot(ship.SpatialState)); ToShipSpatialStateSnapshot(ship.SpatialState));
private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship)
{
var progress = ship.State switch
{
ShipState.SpoolingFtl => CreateShipActionProgress("FTL spool", ship.ActionTimer, MathF.Max(ship.Definition.SpoolTime, 0.1f)),
ShipState.Ftl => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("FTL", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
ShipState.SpoolingWarp => CreateShipActionProgress("Warp spool", ship.ActionTimer, MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f)),
ShipState.Warping => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("Warp", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
ShipState.Mining => CreateShipActionProgress("Mining", ship.ActionTimer, MathF.Max(world.Balance.MiningCycleSeconds, 0.1f)),
ShipState.Docking => CreateShipActionProgress("Docking", ship.ActionTimer, MathF.Max(world.Balance.DockingDuration, 0.1f)),
ShipState.Undocking => CreateShipActionProgress("Undocking", ship.ActionTimer, MathF.Max(world.Balance.UndockingDuration, 0.1f)),
ShipState.Transferring => CreateShipRemainingActionProgress("Transfer", ship.TrackedActionTotal, GetShipCargoAmount(ship)),
ShipState.Refueling => CreateShipRemainingActionProgress(
"Refuel",
ship.TrackedActionTotal,
MathF.Max(0f, GetShipRefuelTarget(ship, world) - GetInventoryAmount(ship.Inventory, "fuel"))),
ShipState.Loading => CreateShipRemainingActionProgress(
"Load workers",
ship.TrackedActionTotal,
MathF.Max(0f, ship.TrackedActionTotal - ship.WorkerPopulation)),
ShipState.Unloading => CreateShipRemainingActionProgress(
"Unload workers",
ship.TrackedActionTotal,
ship.WorkerPopulation),
ShipState.DeliveringConstruction => ship.ControllerTask.TargetEntityId is null
? null
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
? null
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, GetRemainingConstructionDelivery(world, site)),
_ => null,
};
return progress;
}
private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) =>
new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f));
private static ShipActionProgressSnapshot? CreateShipRemainingActionProgress(string label, float totalAmount, float remainingAmount)
{
if (totalAmount <= 0.01f)
{
return null;
}
var progress = 1f - Math.Clamp(remainingAmount / totalAmount, 0f, 1f);
return new ShipActionProgressSnapshot(label, progress);
}
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) => private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
inventory inventory
.Where(entry => entry.Value > 0.001f) .Where(entry => entry.Value > 0.001f)
@@ -647,9 +764,9 @@ public sealed partial class SimulationEngine
private static void EmitShipStateEvents( private static void EmitShipStateEvents(
ShipRuntime ship, ShipRuntime ship,
string previousState, ShipState previousState,
string previousBehavior, string previousBehavior,
string previousTask, ControllerTaskKind previousTask,
string controllerEvent, string controllerEvent,
ICollection<SimulationEventRecord> events) ICollection<SimulationEventRecord> events)
{ {
@@ -657,7 +774,7 @@ public sealed partial class SimulationEngine
if (previousState != ship.State) if (previousState != ship.State)
{ {
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc)); events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
} }
if (previousBehavior != ship.DefaultBehavior.Kind) if (previousBehavior != ship.DefaultBehavior.Kind)
@@ -667,7 +784,7 @@ public sealed partial class SimulationEngine
if (previousTask != ship.ControllerTask.Kind) if (previousTask != ship.ControllerTask.Kind)
{ {
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc)); events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
} }
if (controllerEvent != "none") if (controllerEvent != "none")

View File

@@ -65,7 +65,7 @@ public sealed partial class SimulationEngine
continue; continue;
} }
var remaining = MathF.Max(0f, required - GetInventoryAmount(site.DeliveredItems, order.ItemId)); var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId));
order.RemainingAmount = remaining; order.RemainingAmount = remaining;
order.State = remaining <= 0.01f order.State = remaining <= 0.01f
? MarketOrderStateKinds.Filled ? MarketOrderStateKinds.Filled
@@ -78,11 +78,6 @@ public sealed partial class SimulationEngine
private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId) private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
{ {
if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal))
{
return true;
}
if (station.ActiveConstruction is not null) if (station.ActiveConstruction is not null)
{ {
return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal) return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal)
@@ -111,9 +106,35 @@ public sealed partial class SimulationEngine
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world) private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{ {
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" }) var priorities = GetFactionExpansionPressure(world, station.FactionId) > 0f
? new (string ModuleId, int TargetCount)[]
{
("gas-tank", 1),
("fuel-processor", 1),
("refinery-stack", 1),
("container-bay", 1),
("fabricator-array", 2),
("component-factory", 1),
("ship-factory", 1),
("dock-bay-small", 2),
("solar-array", 2),
}
: new (string ModuleId, int TargetCount)[]
{
("gas-tank", 1),
("fuel-processor", 1),
("refinery-stack", 1),
("container-bay", 1),
("fabricator-array", 2),
("component-factory", 1),
("ship-factory", 1),
("solar-array", 2),
("dock-bay-small", 2),
};
foreach (var (moduleId, targetCount) in priorities)
{ {
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal) if (CountModules(station.InstalledModules, moduleId) < targetCount
&& world.ModuleRecipes.ContainsKey(moduleId)) && world.ModuleRecipes.ContainsKey(moduleId))
{ {
return moduleId; return moduleId;
@@ -133,7 +154,10 @@ public sealed partial class SimulationEngine
{ {
order.State = MarketOrderStateKinds.Cancelled; order.State = MarketOrderStateKinds.Cancelled;
order.RemainingAmount = 0f; order.RemainingAmount = 0f;
world.MarketOrders.Remove(order);
} }
station.MarketOrderIds.Remove(orderId);
} }
site.MarketOrderIds.Clear(); site.MarketOrderIds.Clear();
@@ -214,7 +238,7 @@ public sealed partial class SimulationEngine
{ {
var padCount = Math.Max(1, GetDockingPadCount(station)); var padCount = Math.Max(1, GetDockingPadCount(station));
var angle = ((MathF.PI * 2f) / padCount) * padIndex; var angle = ((MathF.PI * 2f) / padCount) * padIndex;
var radius = station.Definition.Radius + 14f; var radius = station.Definition.Radius + 18f;
return new Vector3( return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius), station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y, station.Position.Y,
@@ -225,7 +249,7 @@ public sealed partial class SimulationEngine
{ {
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal)); var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f); var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Definition.Radius + 34f; var radius = station.Definition.Radius + 24f;
return new Vector3( return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius), station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y, station.Position.Y,
@@ -259,4 +283,25 @@ public sealed partial class SimulationEngine
ship.AssignedDockingPadIndex is int padIndex ship.AssignedDockingPadIndex is int padIndex
? GetDockingPadPosition(station, padIndex) ? GetDockingPadPosition(station, padIndex)
: station.Position; : station.Position;
private static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Definition.Radius + 78f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
private static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
return new Vector3(
nodePosition.X + (MathF.Cos(angle) * radius),
nodePosition.Y,
nodePosition.Z + (MathF.Sin(angle) * radius));
}
} }

View File

@@ -14,6 +14,17 @@ public sealed partial class SimulationEngine
return true; return true;
} }
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
{
if (ship.TrackedActionKey == actionKey)
{
return;
}
ship.TrackedActionKey = actionKey;
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
}
internal static float GetShipCargoAmount(ShipRuntime ship) internal static float GetShipCargoAmount(ShipRuntime ship)
{ {
var cargoItemId = ship.Definition.CargoItemId; var cargoItemId = ship.Definition.CargoItemId;
@@ -26,11 +37,20 @@ public sealed partial class SimulationEngine
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node)) if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node))
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
{
ship.ActionTimer = 0f;
ship.State = ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return "cargo-full";
}
ship.TargetPosition = task.TargetPosition.Value; ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value); var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold) if (distance > task.Threshold)
@@ -38,42 +58,47 @@ public sealed partial class SimulationEngine
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "mining-approach"; ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds); ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none"; return "none";
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "mining"; ship.State = ShipState.Mining;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds)) if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{ {
return "none"; return "none";
} }
var cargoAmount = GetShipCargoAmount(ship); var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount); var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
mined = MathF.Min(mined, node.OreRemaining); mined = MathF.Min(mined, node.OreRemaining);
if (mined <= 0.01f)
{
ship.ActionTimer = 0f;
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
ship.TargetPosition = ship.Position;
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
}
if (ship.Definition.CargoItemId is not null) if (ship.Definition.CargoItemId is not null)
{ {
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined); AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
} }
node.OreRemaining -= mined; node.OreRemaining -= mined;
if (node.OreRemaining <= 0f) node.OreRemaining = MathF.Max(0f, node.OreRemaining);
{
node.OreRemaining = node.MaxOre;
}
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none"; return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
} }
@@ -84,7 +109,7 @@ public sealed partial class SimulationEngine
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId); var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null) if (station is null || task.TargetPosition is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -93,7 +118,7 @@ public sealed partial class SimulationEngine
if (padIndex is null) if (padIndex is null)
{ {
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "awaiting-dock"; ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id); ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition); var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
@@ -113,37 +138,37 @@ public sealed partial class SimulationEngine
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "docking-approach"; ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds); ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds);
return "none"; return "none";
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "docking"; ship.State = ShipState.Docking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration)) if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{ {
return "none"; return "none";
} }
ship.State = "docked"; ship.State = ShipState.Docked;
ship.DockedStationId = station.Id; ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id); station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition; ship.Position = padPosition;
@@ -155,7 +180,7 @@ public sealed partial class SimulationEngine
{ {
if (ship.DockedStationId is null) if (ship.DockedStationId is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -165,15 +190,14 @@ public sealed partial class SimulationEngine
{ {
ship.DockedStationId = null; ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null; ship.AssignedDockingPadIndex = null;
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -181,7 +205,8 @@ public sealed partial class SimulationEngine
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition; ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "transferring"; ship.State = ShipState.Transferring;
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var cargoItemId = ship.Definition.CargoItemId; var cargoItemId = ship.Definition.CargoItemId;
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds); var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
if (cargoItemId is not null) if (cargoItemId is not null)
@@ -201,11 +226,11 @@ public sealed partial class SimulationEngine
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none"; return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none";
} }
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
if (ship.DockedStationId is null) if (ship.DockedStationId is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -215,15 +240,14 @@ public sealed partial class SimulationEngine
{ {
ship.DockedStationId = null; ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null; ship.AssignedDockingPadIndex = null;
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -231,8 +255,58 @@ public sealed partial class SimulationEngine
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition; ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "refueling"; ship.State = ShipState.Loading;
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel")); BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
var cargoItemId = ship.Definition.CargoItemId;
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
var moved = cargoItemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, cargoItemId));
if (cargoItemId is not null && moved > 0.01f)
{
RemoveInventory(station.Inventory, cargoItemId, moved);
AddInventory(ship.Inventory, cargoItemId, moved);
}
return cargoItemId is null
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|| GetInventoryAmount(station.Inventory, cargoItemId) <= 0.01f
? "loaded"
: "none";
}
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var station = ResolveShipSupportStation(ship, world);
if (station is null)
{
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = ShipState.Refueling;
var refuelTarget = GetShipRefuelTarget(ship, world);
BeginTrackedAction(ship, "refueling", MathF.Max(0f, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel")));
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel"));
var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel")); var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel"));
if (moved > 0.01f) if (moved > 0.01f)
{ {
@@ -240,31 +314,40 @@ public sealed partial class SimulationEngine
AddInventory(ship.Inventory, "fuel", moved); AddInventory(ship.Inventory, "fuel", moved);
} }
return !NeedsRefuel(ship) ? "refueled" : "none"; return !NeedsRefuel(ship, world) ? "refueled" : "none";
} }
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null) var station = ResolveShipSupportStation(ship, world);
if (station is null || ship.DefaultBehavior.ModuleId is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{ {
ship.AssignedDockingPadIndex = null; ship.AssignedDockingPadIndex = null;
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -272,22 +355,22 @@ public sealed partial class SimulationEngine
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id)) if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{ {
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "waiting-materials"; ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = supportPosition;
return "none"; return "none";
} }
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id) if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{ {
ship.State = "construction-blocked"; ship.State = ShipState.ConstructionBlocked;
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = supportPosition;
return "none"; return "none";
} }
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition; ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "constructing"; ship.State = ShipState.Constructing;
station.ActiveConstruction.ProgressSeconds += deltaSeconds; station.ActiveConstruction.ProgressSeconds += deltaSeconds;
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds) if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
{ {
@@ -301,34 +384,49 @@ public sealed partial class SimulationEngine
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
if (ship.DockedStationId is null) var station = ResolveShipSupportStation(ship, world);
if (station is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId); var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active) if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{
ship.State = ShipState.LocalFlight;
ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) || !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition; ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "delivering-construction"; ship.State = ShipState.DeliveringConstruction;
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
if (site.StationId is not null)
{
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
}
foreach (var required in site.RequiredItems) foreach (var required in site.RequiredItems)
{ {
@@ -349,49 +447,58 @@ public sealed partial class SimulationEngine
RemoveInventory(station.Inventory, required.Key, moved); RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved); AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved); AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(site) ? "construction-delivered" : "none"; return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
} }
return IsConstructionSiteReady(site) ? "construction-delivered" : "none"; return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
} }
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
if (ship.DockedStationId is null) var station = ResolveShipSupportStation(ship, world);
if (station is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId); var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active) if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe)) var supportPosition = ResolveShipSupportPosition(ship, station);
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
{ {
ship.State = "waiting-materials"; ship.State = ShipState.LocalFlight;
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = supportPosition;
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none"; return "none";
} }
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) || !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.TargetPosition = GetShipDockedPosition(ship, station); ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition; ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f; ship.ActionTimer = 0f;
ship.State = "constructing"; ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id); site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds; site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration) if (site.Progress < recipe.Duration)
@@ -404,22 +511,38 @@ public sealed partial class SimulationEngine
return "site-constructed"; return "site-constructed";
} }
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
ship.DockedStationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
: null;
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
ship.DockedStationId is not null
? GetShipDockedPosition(ship, station)
: GetConstructionHoldPosition(station, ship.Id);
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds) private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{ {
if (ship.DockedStationId is null || !CanTransportWorkers(ship)) if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{ {
ship.State = "blocked"; ship.State = ShipState.Blocked;
return "failed"; return "failed";
} }
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || station.Population <= 0.01f) if (station is null || station.Population <= 0.01f)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
return "none"; return "none";
} }
var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation); var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
var totalTransfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
transfer = MathF.Min(transfer, 4f * deltaSeconds); transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f) if (transfer <= 0.01f)
{ {
@@ -428,7 +551,8 @@ public sealed partial class SimulationEngine
station.Population = MathF.Max(0f, station.Population - transfer); station.Population = MathF.Max(0f, station.Population - transfer);
ship.WorkerPopulation += transfer; ship.WorkerPopulation += transfer;
ship.State = "loading"; ship.State = ShipState.Loading;
BeginTrackedAction(ship, "loading", totalTransfer);
return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none"; return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none";
} }
@@ -436,18 +560,19 @@ public sealed partial class SimulationEngine
{ {
if (ship.DockedStationId is null || !CanTransportWorkers(ship)) if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{ {
ship.State = "blocked"; ship.State = ShipState.Blocked;
return "failed"; return "failed";
} }
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId); var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || ship.WorkerPopulation <= 0.01f) if (station is null || ship.WorkerPopulation <= 0.01f)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
return "none"; return "none";
} }
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population)); var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
var totalTransfer = transfer;
transfer = MathF.Min(transfer, 4f * deltaSeconds); transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f) if (transfer <= 0.01f)
{ {
@@ -456,7 +581,8 @@ public sealed partial class SimulationEngine
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer); ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer);
station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer); station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer);
ship.State = "unloading"; ship.State = ShipState.Unloading;
BeginTrackedAction(ship, "unloading", totalTransfer);
return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none"; return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none";
} }
@@ -465,7 +591,7 @@ public sealed partial class SimulationEngine
var task = ship.ControllerTask; var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null) if (ship.DockedStationId is null || task.TargetPosition is null)
{ {
ship.State = "idle"; ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
@@ -477,19 +603,19 @@ public sealed partial class SimulationEngine
ship.TargetPosition = undockTarget; ship.TargetPosition = undockTarget;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds)) if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds)) if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{ {
ship.State = "power-starved"; ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position; ship.TargetPosition = ship.Position;
return "none"; return "none";
} }
ship.State = "undocking"; ship.State = ShipState.Undocking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration)) if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{ {
if (station is not null) if (station is not null)
@@ -516,4 +642,7 @@ public sealed partial class SimulationEngine
ship.AssignedDockingPadIndex = null; ship.AssignedDockingPadIndex = null;
return "undocked"; return "undocked";
} }
private static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
} }

View File

@@ -41,7 +41,7 @@ public sealed partial class SimulationEngine
{ {
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = commander.ActiveTask.Kind, Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
Status = commander.ActiveTask.Status, Status = commander.ActiveTask.Status,
CommanderId = commander.Id, CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId, TargetEntityId = commander.ActiveTask.TargetEntityId,
@@ -81,8 +81,8 @@ public sealed partial class SimulationEngine
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId; commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
} }
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind }; commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
commander.ActiveTask.Kind = ship.ControllerTask.Kind; commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
commander.ActiveTask.Status = ship.ControllerTask.Status; commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId; commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId; commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
@@ -121,7 +121,7 @@ public sealed partial class SimulationEngine
{ {
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "travel", Kind = ControllerTaskKind.Travel,
Status = WorkStatus.Active, Status = WorkStatus.Active,
CommanderId = commander?.Id, CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId, TargetSystemId = ship.Order.DestinationSystemId,
@@ -144,10 +144,13 @@ public sealed partial class SimulationEngine
behavior.StationId = refinery?.Id; behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null var node = behavior.NodeId is null
? world.Nodes ? world.Nodes
.Where(candidate => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId) .Where(candidate =>
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
candidate.ItemId == resourceItemId &&
candidate.OreRemaining > 0.01f)
.OrderByDescending(candidate => candidate.OreRemaining) .OrderByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault() .FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId); : world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule)) if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
{ {
@@ -157,13 +160,29 @@ public sealed partial class SimulationEngine
} }
behavior.NodeId ??= node.Id; behavior.NodeId ??= node.Id;
if (NeedsEmergencyReturn(ship, world) && behavior.Phase is "travel-to-node" or "extract")
{
behavior.Phase = "travel-to-station";
}
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
&& behavior.Phase is "travel-to-node" or "extract")
{
behavior.Phase = "travel-to-station";
}
if (ship.DockedStationId == refinery.Id) if (ship.DockedStationId == refinery.Id)
{ {
if (GetShipCargoAmount(ship) > 0.01f) if (GetShipCargoAmount(ship) > 0.01f)
{ {
behavior.Phase = "unload"; behavior.Phase = "unload";
} }
else if (NeedsRefuel(ship)) else if (behavior.Phase == "undock")
{
// Keep the post-refuel departure decision stable for the current dock cycle.
behavior.Phase = "undock";
}
else if (NeedsRefuel(ship, world))
{ {
behavior.Phase = "refuel"; behavior.Phase = "refuel";
} }
@@ -172,7 +191,7 @@ public sealed partial class SimulationEngine
behavior.Phase = "undock"; behavior.Phase = "undock";
} }
} }
else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock") else if (NeedsRefuel(ship, world) && behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
{ {
behavior.Phase = "travel-to-station"; behavior.Phase = "travel-to-station";
} }
@@ -180,19 +199,20 @@ public sealed partial class SimulationEngine
switch (behavior.Phase) switch (behavior.Phase)
{ {
case "extract": case "extract":
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "extract", Kind = ControllerTaskKind.Extract,
TargetEntityId = node.Id, TargetEntityId = node.Id,
TargetSystemId = node.SystemId, TargetSystemId = node.SystemId,
TargetPosition = node.Position, TargetPosition = extractionPosition,
Threshold = 14f, Threshold = 5f,
}; };
break; break;
case "travel-to-station": case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "travel", Kind = ControllerTaskKind.Travel,
TargetEntityId = refinery.Id, TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId, TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position, TargetPosition = refinery.Position,
@@ -202,7 +222,7 @@ public sealed partial class SimulationEngine
case "dock": case "dock":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "dock", Kind = ControllerTaskKind.Dock,
TargetEntityId = refinery.Id, TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId, TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position, TargetPosition = refinery.Position,
@@ -212,7 +232,7 @@ public sealed partial class SimulationEngine
case "unload": case "unload":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "unload", Kind = ControllerTaskKind.Unload,
TargetEntityId = refinery.Id, TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId, TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position, TargetPosition = refinery.Position,
@@ -222,7 +242,7 @@ public sealed partial class SimulationEngine
case "refuel": case "refuel":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "refuel", Kind = ControllerTaskKind.Refuel,
TargetEntityId = refinery.Id, TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId, TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position, TargetPosition = refinery.Position,
@@ -232,7 +252,7 @@ public sealed partial class SimulationEngine
case "undock": case "undock":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "undock", Kind = ControllerTaskKind.Undock,
TargetEntityId = refinery.Id, TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId, TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z), TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
@@ -242,7 +262,7 @@ public sealed partial class SimulationEngine
default: default:
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "travel", Kind = ControllerTaskKind.Travel,
TargetEntityId = node.Id, TargetEntityId = node.Id,
TargetSystemId = node.SystemId, TargetSystemId = node.SystemId,
TargetPosition = node.Position, TargetPosition = node.Position,
@@ -278,6 +298,153 @@ public sealed partial class SimulationEngine
return bestOrder.Station ?? preferred; return bestOrder.Station ?? preferred;
} }
internal static StationRuntime? SelectBestSellStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
? null
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
var bestOrder = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Sell &&
order.ConstructionSiteId is null &&
order.State != MarketOrderStateKinds.Cancelled &&
order.ItemId == itemId &&
order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
.Where(entry => entry.Station is not null && GetInventoryAmount(entry.Station!.Inventory, itemId) > 0.01f)
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? preferred;
}
internal void PlanEnergySupply(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var cargoItemId = ship.Definition.CargoItemId;
if (cargoItemId is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var cargoAmount = GetShipCargoAmount(ship);
if (cargoAmount > 0.01f)
{
var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
if (destination is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.StationId = destination.Id;
switch (behavior.Phase)
{
case "dock":
case "unload":
case "refuel":
case "undock":
ship.ControllerTask = CreateStationSupportTask(world, ship, destination, behavior.Phase);
break;
default:
behavior.Phase = "travel-to-destination";
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = destination.Id,
TargetSystemId = destination.SystemId,
TargetPosition = destination.Position,
Threshold = 18f,
};
break;
}
return;
}
var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId);
if (source is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.StationId = source.Id;
switch (behavior.Phase)
{
case "dock":
case "load":
case "refuel":
case "undock":
ship.ControllerTask = CreateStationSupportTask(world, ship, source, behavior.Phase);
break;
default:
behavior.Phase = "travel-to-source";
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Travel,
TargetEntityId = source.Id,
TargetSystemId = source.SystemId,
TargetPosition = source.Position,
Threshold = 18f,
};
break;
}
}
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
phase switch
{
"dock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Dock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"load" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Load,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"unload" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Unload,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 8f,
},
"refuel" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Refuel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 12f,
},
"undock" => new ControllerTaskRuntime
{
Kind = ControllerTaskKind.Undock,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
Threshold = 8f,
},
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
};
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world) internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
{ {
var behavior = ship.DefaultBehavior; var behavior = ship.DefaultBehavior;
@@ -298,17 +465,36 @@ public sealed partial class SimulationEngine
return; return;
} }
if (ship.DockedStationId == station.Id) if (ship.DockedStationId is not null)
{ {
if (NeedsRefuel(ship)) var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (dockedStation is not null)
{
dockedStation.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(dockedStation, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.Position = GetConstructionHoldPosition(station, ship.Id);
ship.TargetPosition = ship.Position;
}
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
var isAtConstructionHold = ship.SystemId == station.SystemId
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
if (isAtConstructionHold)
{
if (NeedsRefuel(ship, world))
{ {
behavior.Phase = "refuel"; behavior.Phase = "refuel";
} }
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site)) else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
{ {
behavior.Phase = "deliver-to-site"; behavior.Phase = "deliver-to-site";
} }
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(site)) else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
{ {
behavior.Phase = "build-site"; behavior.Phase = "build-site";
} }
@@ -325,81 +511,71 @@ public sealed partial class SimulationEngine
behavior.Phase = "wait-for-materials"; behavior.Phase = "wait-for-materials";
} }
} }
else if (behavior.Phase is not "travel-to-station" and not "dock") else if (behavior.Phase != "travel-to-station")
{ {
behavior.Phase = "travel-to-station"; behavior.Phase = "travel-to-station";
} }
switch (behavior.Phase) switch (behavior.Phase)
{ {
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "dock",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = station.Definition.Radius + 4f,
};
break;
case "refuel": case "refuel":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "refuel", Kind = ControllerTaskKind.Refuel,
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
TargetPosition = station.Position, TargetPosition = constructionHoldPosition,
Threshold = 0f, Threshold = 10f,
}; };
break; break;
case "construct-module": case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "construct-module", Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
TargetPosition = station.Position, TargetPosition = constructionHoldPosition,
Threshold = 0f, Threshold = 10f,
}; };
break; break;
case "deliver-to-site": case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "deliver-construction", Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id, TargetEntityId = site?.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
TargetPosition = station.Position, TargetPosition = constructionHoldPosition,
Threshold = 0f, Threshold = 10f,
}; };
break; break;
case "build-site": case "build-site":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "build-construction-site", Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id, TargetEntityId = site?.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
TargetPosition = station.Position, TargetPosition = constructionHoldPosition,
Threshold = 0f, Threshold = 10f,
}; };
break; break;
case "wait-for-materials": case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "idle", Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
TargetPosition = station.Position, TargetPosition = constructionHoldPosition,
Threshold = 0f, Threshold = 0f,
}; };
break; break;
default: default:
ship.ControllerTask = new ControllerTaskRuntime ship.ControllerTask = new ControllerTaskRuntime
{ {
Kind = "travel", Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id, TargetEntityId = station.Id,
TargetSystemId = station.SystemId, TargetSystemId = station.SystemId,
TargetPosition = station.Position, TargetPosition = constructionHoldPosition,
Threshold = station.Definition.Radius + 8f, Threshold = 10f,
}; };
behavior.Phase = "travel-to-station"; behavior.Phase = "travel-to-station";
break; break;
@@ -412,7 +588,7 @@ public sealed partial class SimulationEngine
if (ship.Order is not null && controllerEvent == "arrived") if (ship.Order is not null && controllerEvent == "arrived")
{ {
ship.Order = null; ship.Order = null;
ship.ControllerTask.Kind = "idle"; ship.ControllerTask.Kind = ControllerTaskKind.Idle;
if (commander is not null) if (commander is not null)
{ {
commander.ActiveOrder = null; commander.ActiveOrder = null;
@@ -439,16 +615,20 @@ public sealed partial class SimulationEngine
} }
} }
private static void TrackHistory(ShipRuntime ship) private static void TrackHistory(ShipRuntime ship, string controllerEvent)
{ {
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}"; var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
if (signature == ship.LastSignature) if (signature == ship.LastSignature)
{ {
return; return;
} }
ship.LastSignature = signature; ship.LastSignature = signature;
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}"); var target = ship.ControllerTask.TargetEntityId
?? ship.ControllerTask.TargetSystemId
?? "none";
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
if (ship.History.Count > 18) if (ship.History.Count > 18)
{ {
ship.History.RemoveAt(0); ship.History.RemoveAt(0);
@@ -458,10 +638,27 @@ public sealed partial class SimulationEngine
private static ControllerTaskRuntime CreateIdleTask(float threshold) => private static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new() new()
{ {
Kind = "idle", Kind = ControllerTaskKind.Idle,
Threshold = threshold, Threshold = threshold,
}; };
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
{
"travel" => ControllerTaskKind.Travel,
"extract" => ControllerTaskKind.Extract,
"dock" => ControllerTaskKind.Dock,
"load" => ControllerTaskKind.Load,
"unload" => ControllerTaskKind.Unload,
"refuel" => ControllerTaskKind.Refuel,
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
"load-workers" => ControllerTaskKind.LoadWorkers,
"unload-workers" => ControllerTaskKind.UnloadWorkers,
"construct-module" => ControllerTaskKind.ConstructModule,
"undock" => ControllerTaskKind.Undock,
_ => ControllerTaskKind.Idle,
};
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task) private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
{ {
if (commander is null) if (commander is null)
@@ -471,7 +668,7 @@ public sealed partial class SimulationEngine
commander.ActiveTask = new CommanderTaskRuntime commander.ActiveTask = new CommanderTaskRuntime
{ {
Kind = task.Kind, Kind = task.Kind.ToContractValue(),
Status = task.Status, Status = task.Status,
TargetEntityId = task.TargetEntityId, TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId, TargetNodeId = task.TargetNodeId,

View File

@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine public sealed partial class SimulationEngine
{ {
private const int StrategicControlTargetSystems = 5;
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events) private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{ {
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal); var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
@@ -62,18 +64,27 @@ public sealed partial class SimulationEngine
var desiredOrders = new List<DesiredMarketOrder>(); var desiredOrders = new List<DesiredMarketOrder>();
var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f); var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f);
var energyCellReserve = HasStationModules(station, "power-core", "liquid-tank") ? MathF.Max(20f, CountModules(station.InstalledModules, "power-core") * 40f) : 0f;
var waterReserve = MathF.Max(30f, station.Population * 3f); var waterReserve = MathF.Max(30f, station.Population * 3f);
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f; var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 0f; var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
var gasReserve = CanProcessFuel(station) ? 120f : 0f; var gasReserve = CanProcessFuel(station) ? 120f : 0f;
var shipPartsReserve = HasStationModules(station, "fabricator-array")
&& !HasStationModules(station, "component-factory", "ship-factory")
&& FactionNeedsMoreWarships(world, station.FactionId)
? 90f
: 0f;
AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f); AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f);
AddDemandOrder(desiredOrders, station, "energy-cell", energyCellReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f); AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f); AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f); AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f);
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f); AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f);
AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f); AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f);
AddSupplyOrder(desiredOrders, station, "energy-cell", energyCellReserve * 1.8f, reserveFloor: energyCellReserve, valuationBase: 0.82f);
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f); AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f); AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
AddSupplyOrder(desiredOrders, station, "gas", gasReserve * 1.4f, reserveFloor: gasReserve, valuationBase: 0.72f); AddSupplyOrder(desiredOrders, station, "gas", gasReserve * 1.4f, reserveFloor: gasReserve, valuationBase: 0.72f);
@@ -84,56 +95,160 @@ public sealed partial class SimulationEngine
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events) private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{ {
var recipe = SelectProductionRecipe(world, station);
if (recipe is null || station.EnergyStored <= 0.01f)
{
station.ProcessTimer = 0f;
return;
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
station.ProcessTimer = 0f;
return;
}
station.ProcessTimer += deltaSeconds * station.WorkforceEffectiveRatio;
if (station.ProcessTimer < recipe.Duration)
{
return;
}
station.ProcessTimer = 0f;
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
var produced = 0f;
foreach (var output in recipe.Outputs)
{
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
}
if (produced <= 0.01f)
{
return;
}
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId); var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
if (faction is not null) foreach (var laneKey in GetStationProductionLanes(station))
{ {
faction.GoodsProduced += produced; var recipe = SelectProductionRecipe(world, station, laneKey);
if (recipe is null || station.EnergyStored <= 0.01f)
{
station.ProductionLaneTimers[laneKey] = 0f;
continue;
}
var throughput = GetStationProductionThroughput(station, recipe);
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds * throughput))
{
station.ProductionLaneTimers[laneKey] = 0f;
continue;
}
var produced = 0f;
station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput);
while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe))
{
station.ProductionLaneTimers[laneKey] -= recipe.Duration;
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
if (recipe.ShipOutputId is not null)
{
produced += CompleteShipRecipe(world, station, recipe, events);
continue;
}
foreach (var output in recipe.Outputs)
{
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
}
}
if (produced <= 0.01f)
{
continue;
}
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
if (faction is not null)
{
faction.GoodsProduced += produced;
}
} }
} }
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station) => private static IEnumerable<string> GetStationProductionLanes(StationRuntime station)
{
if (CountModules(station.InstalledModules, "refinery-stack") > 0)
{
yield return "refinery";
}
if (CountModules(station.InstalledModules, "fuel-processor") > 0)
{
yield return "fuel";
}
if (CountModules(station.InstalledModules, "fabricator-array") > 0)
{
yield return "fabrication";
}
if (CountModules(station.InstalledModules, "component-factory") > 0)
{
yield return "components";
}
if (CountModules(station.InstalledModules, "ship-factory") > 0)
{
yield return "shipyard";
}
}
private static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
world.Recipes.Values world.Recipes.Values
.Where(recipe => RecipeAppliesToStation(station, recipe)) .Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(recipe), laneKey, StringComparison.Ordinal))
.OrderByDescending(recipe => recipe.Priority) .OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe)); .FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
private static string? GetStationProductionLaneKey(RecipeDefinition recipe)
{
if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal))
{
return "fuel";
}
if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal))
{
return "refinery";
}
if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal))
{
return "fabrication";
}
if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal))
{
return "components";
}
if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal))
{
return "shipyard";
}
return null;
}
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
var priority = (float)recipe.Priority;
var producesFuel = recipe.Outputs.Any(output => string.Equals(output.ItemId, "fuel", StringComparison.Ordinal));
if (producesFuel)
{
var fuelCapacity = MathF.Max(GetStationFuelCapacity(station), 1f);
var fuelRatio = GetInventoryAmount(station.Inventory, "fuel") / fuelCapacity;
priority += (1f - Math.Clamp(fuelRatio, 0f, 1f)) * 200f;
}
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionNeedsMoreWarships(world, station.FactionId) ? 1f : 0f;
priority += recipe.Id switch
{
"ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory")
? -140f * MathF.Max(expansionPressure, fleetPressure)
: 280f * MathF.Max(expansionPressure, fleetPressure),
"hull-fabrication" => 180f * expansionPressure,
"equipment-assembly" => 170f * expansionPressure,
"gun-assembly" => 160f * expansionPressure,
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
=> 220f * MathF.Max(expansionPressure, fleetPressure),
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
"ammo-fabrication" => -80f * expansionPressure,
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
=> -120f * expansionPressure,
_ => 0f,
};
return priority;
}
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe) private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
{ {
var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal) var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal)
@@ -144,6 +259,21 @@ public sealed partial class SimulationEngine
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe) private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{ {
if (recipe.ShipOutputId is not null)
{
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|| !CanLaunchShipFromStation(station))
{
return false;
}
if (!string.Equals(shipDefinition.Role, "military", StringComparison.Ordinal)
|| !FactionNeedsMoreWarships(world, station.FactionId))
{
return false;
}
}
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount)) if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
{ {
return false; return false;
@@ -165,7 +295,8 @@ public sealed partial class SimulationEngine
return false; return false;
} }
if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity)) var capacity = GetStationStorageCapacity(station, itemDefinition.Storage);
if (capacity <= 0.01f)
{ {
return false; return false;
} }
@@ -259,5 +390,151 @@ public sealed partial class SimulationEngine
private static bool CanProcessFuel(StationRuntime station) => private static bool CanProcessFuel(StationRuntime station) =>
HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank"); HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank");
private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
{
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
{
return 0f;
}
var spawnPosition = new Vector3(station.Position.X + station.Definition.Radius + 32f, station.Position.Y, station.Position.Z);
var ship = new ShipRuntime
{
Id = $"ship-{world.Ships.Count + 1}",
SystemId = station.SystemId,
Definition = definition,
FactionId = station.FactionId,
Position = spawnPosition,
TargetPosition = spawnPosition,
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold),
Health = definition.MaxHealth,
};
ship.Inventory["fuel"] = 120f;
world.Ships.Add(ship);
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
{
faction.ShipsBuilt += 1;
}
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Definition.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
return 1f;
}
private static bool CanLaunchShipFromStation(StationRuntime station) =>
HasStationModules(station, "power-core", "ship-factory", "container-bay", "dock-bay-small");
private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId)
{
var militaryShipCount = world.Ships.Count(ship =>
ship.FactionId == factionId
&& string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal));
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
var expansionDeficit = Math.Max(0, targetSystems - controlledSystems);
var targetWarships = Math.Max(2, (controlledSystems * 2) + (expansionDeficit * 3));
return militaryShipCount < targetWarships;
}
private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
{
return world.Claims
.Where(claim => claim.State != ClaimStateKinds.Destroyed)
.Select(claim => claim.SystemId)
.Distinct(StringComparer.Ordinal)
.Count(systemId => FactionControlsSystem(world, factionId, systemId));
}
private static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
{
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
var deficit = Math.Max(0, targetSystems - controlledSystems);
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
}
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
{
var buildableLocations = world.Claims
.Where(claim =>
claim.SystemId == systemId &&
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active)
.ToList();
if (buildableLocations.Count == 0)
{
return false;
}
var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId);
return ownedLocations > (buildableLocations.Count / 2f);
}
private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
{
CurrentSystemId = station.SystemId,
SpaceLayer = SpaceLayerKinds.LocalSpace,
CurrentNodeId = station.NodeId,
CurrentBubbleId = station.BubbleId,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKinds.LocalFlight,
};
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
{
if (!string.Equals(definition.Role, "military", StringComparison.Ordinal))
{
return new DefaultBehaviorRuntime
{
Kind = "idle",
};
}
var patrolRadius = station.Definition.Radius + 90f;
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints =
[
new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius),
new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z),
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius),
],
};
}
private static float GetStationProductionThroughput(StationRuntime station, RecipeDefinition recipe)
{
if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal))
{
return Math.Max(1, CountModules(station.InstalledModules, "refinery-stack"));
}
if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal))
{
return Math.Max(1, CountModules(station.InstalledModules, "fuel-processor"));
}
if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal))
{
return Math.Max(1, CountModules(station.InstalledModules, "fabricator-array"));
}
if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal))
{
return Math.Max(1, CountModules(station.InstalledModules, "component-factory"));
}
if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal))
{
return Math.Max(1, CountModules(station.InstalledModules, "ship-factory"));
}
return 1f;
}
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
} }

View File

@@ -4,6 +4,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine public sealed partial class SimulationEngine
{ {
private readonly OrbitalSimulationOptions _orbitalSimulation;
private const float ShipFuelToEnergyRatio = 12f; private const float ShipFuelToEnergyRatio = 12f;
private const float StationFuelToEnergyRatio = 18f; private const float StationFuelToEnergyRatio = 18f;
private const float CapacitorEnergyPerModule = 120f; private const float CapacitorEnergyPerModule = 120f;
@@ -16,7 +17,7 @@ public sealed partial class SimulationEngine
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault(); private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
private static readonly IReadOnlyList<WorldUpdateStep> _worldUpdatePipeline = private static readonly IReadOnlyList<WorldUpdateStep> _worldUpdatePipeline =
[ [
new((engine, world, deltaSeconds, nowUtc, events) => UpdateOrbitalState(world, nowUtc)), new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)),
new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)), new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)),
new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)), new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)),
new((engine, world, deltaSeconds, nowUtc, events) => UpdateStationPower(world, deltaSeconds, events)), new((engine, world, deltaSeconds, nowUtc, events) => UpdateStationPower(world, deltaSeconds, events)),
@@ -29,10 +30,16 @@ public sealed partial class SimulationEngine
new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)), new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)),
]; ];
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
{
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
}
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence) public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
{ {
var events = new List<SimulationEventRecord>(); var events = new List<SimulationEventRecord>();
var nowUtc = DateTimeOffset.UtcNow; var nowUtc = DateTimeOffset.UtcNow;
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
foreach (var step in _worldUpdatePipeline) foreach (var step in _worldUpdatePipeline)
{ {
@@ -54,7 +61,7 @@ public sealed partial class SimulationEngine
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
AdvanceControlState(ship, world, controllerEvent); AdvanceControlState(ship, world, controllerEvent);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds); ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
TrackHistory(ship); TrackHistory(ship, controllerEvent);
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events); EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
} }
@@ -65,6 +72,8 @@ public sealed partial class SimulationEngine
return new WorldDelta( return new WorldDelta(
sequence, sequence,
world.TickIntervalMs, world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc, world.GeneratedAtUtc,
false, false,
events, events,

View File

@@ -0,0 +1,8 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class WorldGenerationOptions
{
public int TargetSystemCount { get; init; } = 160;
public bool IncludeSolSystem { get; init; } = true;
}

View File

@@ -1,18 +1,23 @@
using System.Threading.Channels; using System.Threading.Channels;
using Microsoft.Extensions.Options;
using SpaceGame.Simulation.Api.Contracts; using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation; namespace SpaceGame.Simulation.Api.Simulation;
public sealed class WorldService(IWebHostEnvironment environment) public sealed class WorldService(
IWebHostEnvironment environment,
IOptions<WorldGenerationOptions> worldGenerationOptions,
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
{ {
private const int DeltaHistoryLimit = 256; private const int DeltaHistoryLimit = 256;
private readonly object _sync = new(); private readonly object _sync = new();
private readonly ScenarioLoader _loader = new(environment.ContentRootPath); private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly SimulationEngine _engine = new(); private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly Dictionary<Guid, SubscriptionState> _subscribers = []; private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
private readonly Queue<WorldDelta> _history = []; private readonly Queue<WorldDelta> _history = [];
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load(); private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
private long _sequence; private long _sequence;
public WorldSnapshot GetSnapshot() public WorldSnapshot GetSnapshot()
@@ -98,6 +103,8 @@ public sealed class WorldService(IWebHostEnvironment environment)
var resetDelta = new WorldDelta( var resetDelta = new WorldDelta(
_sequence, _sequence,
_world.TickIntervalMs, _world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
DateTimeOffset.UtcNow, DateTimeOffset.UtcNow,
true, true,
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")], [new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],

View File

@@ -4,5 +4,12 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
},
"WorldGeneration": {
"TargetSystemCount": 1,
"IncludeSolSystem": true
},
"OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0
} }
} }

View File

@@ -5,5 +5,12 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"WorldGeneration": {
"TargetSystemCount": 160,
"IncludeSolSystem": true
},
"OrbitalSimulation": {
"SimulatedSecondsPerRealSecond": 0
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -55,6 +55,7 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController"; import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController"; import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory"; import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import type { SceneNode } from "./viewerScenePrimitives";
import type { FactionSnapshot, ShipSnapshot } from "./contracts"; import type { FactionSnapshot, ShipSnapshot } from "./contracts";
import type { import type {
BubbleVisual, BubbleVisual,
@@ -66,6 +67,7 @@ import type {
MoonVisual, MoonVisual,
NetworkStats, NetworkStats,
NodeVisual, NodeVisual,
OrbitLineVisual,
OrbitalAnchor, OrbitalAnchor,
PerformanceStats, PerformanceStats,
PlanetVisual, PlanetVisual,
@@ -101,6 +103,7 @@ export class ViewerAppController {
private readonly constructionSiteGroup = new THREE.Group(); private readonly constructionSiteGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group(); private readonly shipGroup = new THREE.Group();
private readonly ambienceGroup = new THREE.Group(); private readonly ambienceGroup = new THREE.Group();
private readonly gamePanelEl: HTMLDivElement;
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>(); private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = []; private readonly presentationEntries: PresentationEntry[] = [];
private readonly nodeVisuals = new Map<string, NodeVisual>(); private readonly nodeVisuals = new Map<string, NodeVisual>();
@@ -113,15 +116,20 @@ export class ViewerAppController {
private readonly systemVisuals = new Map<string, SystemVisual>(); private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>(); private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
private readonly planetVisuals: PlanetVisual[] = []; private readonly planetVisuals: PlanetVisual[] = [];
private readonly orbitLines: THREE.Object3D[] = []; private readonly orbitLines: OrbitLineVisual[] = [];
private readonly statusEl: HTMLDivElement; private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement; private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement; private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement; private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement; private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement; private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement; private readonly factionStripEl: HTMLDivElement;
private readonly networkSectionEl: HTMLDivElement;
private readonly networkSummaryEl: HTMLSpanElement;
private readonly networkPanelEl: HTMLDivElement; private readonly networkPanelEl: HTMLDivElement;
private readonly performanceSectionEl: HTMLDivElement;
private readonly performanceSummaryEl: HTMLSpanElement;
private readonly performancePanelEl: HTMLDivElement; private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement; private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement; private readonly historyLayerEl: HTMLDivElement;
@@ -179,15 +187,32 @@ export class ViewerAppController {
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3); const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800); keyLight.position.set(1000, 1200, 800);
this.scene.add(keyLight); this.scene.add(keyLight);
this.scene.add(
this.ambienceGroup,
this.systemGroup,
this.spatialNodeGroup,
this.bubbleGroup,
this.nodeGroup,
this.stationGroup,
this.claimGroup,
this.constructionSiteGroup,
this.shipGroup,
);
const hud = createViewerHud(document); const hud = createViewerHud(document);
this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl; this.statusEl = hud.statusEl;
this.gameSummaryEl = hud.gameSummaryEl;
this.networkSectionEl = hud.networkSectionEl;
this.systemPanelEl = hud.systemPanelEl; this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl; this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl; this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl; this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl; this.detailBodyEl = hud.detailBodyEl;
this.factionStripEl = hud.factionStripEl; this.factionStripEl = hud.factionStripEl;
this.networkSummaryEl = hud.networkSummaryEl;
this.networkPanelEl = hud.networkPanelEl; this.networkPanelEl = hud.networkPanelEl;
this.performanceSectionEl = hud.performanceSectionEl;
this.performanceSummaryEl = hud.performanceSummaryEl;
this.performancePanelEl = hud.performancePanelEl; this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl; this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl; this.historyLayerEl = hud.historyLayerEl;
@@ -200,13 +225,31 @@ export class ViewerAppController {
worldLifecycle: this.worldLifecycle, worldLifecycle: this.worldLifecycle,
interactionController: this.interactionController, interactionController: this.interactionController,
} = createViewerControllers(this)); } = createViewerControllers(this));
this.presentationController.initializeAmbience();
this.container.append(this.renderer.domElement, hud.root); this.container.append(this.renderer.domElement, hud.root);
this.initializePanelToggles();
wireViewerEvents(this); wireViewerEvents(this);
this.onResize(); this.onResize();
this.updateCamera(0); this.updateCamera(0);
} }
private initializePanelToggles() {
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
const toggle = panel.querySelector(".panel-toggle");
if (!(toggle instanceof HTMLButtonElement)) {
continue;
}
toggle.addEventListener("click", () => {
const collapsed = panel.classList.toggle("is-collapsed");
toggle.textContent = collapsed ? "+" : "-";
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
});
}
}
async start() { async start() {
await this.worldLifecycle.bootstrapWorld(); await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render()); this.renderer.setAnimationLoop(() => this.render());
@@ -308,8 +351,8 @@ export class ViewerAppController {
} }
private registerPresentation( private registerPresentation(
detail: THREE.Object3D, detail: SceneNode,
icon: THREE.Sprite, icon: SceneNode,
hideDetailInUniverse: boolean, hideDetailInUniverse: boolean,
hideIconInUniverse = false, hideIconInUniverse = false,
systemId?: string, systemId?: string,
@@ -344,7 +387,7 @@ export class ViewerAppController {
}); });
}; };
private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
setShellReticleOpacity(sprite, opacity); setShellReticleOpacity(sprite, opacity);
} }

View File

@@ -4,6 +4,7 @@ export type {
WorldDelta, WorldDelta,
SimulationEventRecord, SimulationEventRecord,
ObserverScope, ObserverScope,
OrbitalSimulationSnapshot,
} from "./contractsWorld"; } from "./contractsWorld";
export type { export type {
SystemSnapshot, SystemSnapshot,

View File

@@ -32,6 +32,7 @@ export interface ResourceNodeSnapshot {
id: string; id: string;
systemId: string; systemId: string;
localPosition: Vector3Dto; localPosition: Vector3Dto;
anchorNodeId?: string | null;
sourceKind: string; sourceKind: string;
oreRemaining: number; oreRemaining: number;
maxOre: number; maxOre: number;

View File

@@ -1,5 +1,11 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon"; import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface StationActionProgressSnapshot {
lane: string;
label: string;
progress: number;
}
export interface StationSnapshot { export interface StationSnapshot {
id: string; id: string;
label: string; label: string;
@@ -11,8 +17,13 @@ export interface StationSnapshot {
anchorNodeId?: string | null; anchorNodeId?: string | null;
color: string; color: string;
dockedShips: number; dockedShips: number;
dockedShipIds: string[];
dockingPads: number; dockingPads: number;
fuelStored: number;
fuelCapacity: number;
energyStored: number; energyStored: number;
energyCapacity: number;
currentProcesses: StationActionProgressSnapshot[];
inventory: InventoryEntry[]; inventory: InventoryEntry[];
factionId: string; factionId: string;
commanderId?: string | null; commanderId?: string | null;

View File

@@ -19,17 +19,24 @@ export interface ShipSnapshot {
commanderId?: string | null; commanderId?: string | null;
policySetId?: string | null; policySetId?: string | null;
cargoCapacity: number; cargoCapacity: number;
cargoItemId?: string | null;
workerPopulation: number; workerPopulation: number;
energyStored: number; energyStored: number;
inventory: InventoryEntry[]; inventory: InventoryEntry[];
factionId: string; factionId: string;
health: number; health: number;
history: string[]; history: string[];
currentAction?: ShipActionProgressSnapshot | null;
spatialState: ShipSpatialStateSnapshot; spatialState: ShipSpatialStateSnapshot;
} }
export interface ShipDelta extends ShipSnapshot {} export interface ShipDelta extends ShipSnapshot {}
export interface ShipActionProgressSnapshot {
label: string;
progress: number;
}
export interface ShipSpatialStateSnapshot { export interface ShipSpatialStateSnapshot {
spaceLayer: string; spaceLayer: string;
currentSystemId: string; currentSystemId: string;

View File

@@ -33,6 +33,8 @@ export interface WorldSnapshot {
seed: number; seed: number;
sequence: number; sequence: number;
tickIntervalMs: number; tickIntervalMs: number;
orbitalTimeSeconds: number;
orbitalSimulation: OrbitalSimulationSnapshot;
generatedAtUtc: string; generatedAtUtc: string;
systems: SystemSnapshot[]; systems: SystemSnapshot[];
spatialNodes: SpatialNodeSnapshot[]; spatialNodes: SpatialNodeSnapshot[];
@@ -50,6 +52,8 @@ export interface WorldSnapshot {
export interface WorldDelta { export interface WorldDelta {
sequence: number; sequence: number;
tickIntervalMs: number; tickIntervalMs: number;
orbitalTimeSeconds: number;
orbitalSimulation: OrbitalSimulationSnapshot;
generatedAtUtc: string; generatedAtUtc: string;
requiresSnapshotRefresh: boolean; requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[]; events: SimulationEventRecord[];
@@ -83,3 +87,7 @@ export interface ObserverScope {
systemId?: string | null; systemId?: string | null;
bubbleId?: string | null; bubbleId?: string | null;
} }
export interface OrbitalSimulationSnapshot {
simulatedSecondsPerRealSecond: number;
}

View File

@@ -96,7 +96,7 @@ canvas {
.topbar { .topbar {
border-radius: 22px; border-radius: 22px;
padding: 18px 20px; padding: 14px 16px;
pointer-events: auto; pointer-events: auto;
} }
@@ -124,8 +124,48 @@ canvas {
.topbar h2 { .topbar h2 {
color: var(--accent); color: var(--accent);
letter-spacing: 0.16em; letter-spacing: 0.16em;
font-size: 0.72rem; font-size: 0.64rem;
text-transform: uppercase; text-transform: uppercase;
line-height: 1;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-heading-meta {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.panel-summary {
display: none;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
text-align: right;
white-space: nowrap;
}
.panel-toggle {
border: 1px solid rgba(127, 214, 255, 0.2);
background: rgba(127, 214, 255, 0.08);
color: var(--text);
border-radius: 999px;
width: 28px;
height: 28px;
cursor: pointer;
font: inherit;
}
.panel-toggle:hover {
background: rgba(127, 214, 255, 0.16);
} }
.topbar-body { .topbar-body {
@@ -139,7 +179,7 @@ canvas {
.info-panel { .info-panel {
border-radius: 24px; border-radius: 24px;
padding: 18px; padding: 16px;
color: var(--text); color: var(--text);
pointer-events: auto; pointer-events: auto;
overflow: auto; overflow: auto;
@@ -147,7 +187,7 @@ canvas {
.network-panel { .network-panel {
border-radius: 24px; border-radius: 24px;
padding: 18px; padding: 14px 16px;
color: var(--text); color: var(--text);
pointer-events: auto; pointer-events: auto;
} }
@@ -155,7 +195,7 @@ canvas {
.performance-panel { .performance-panel {
width: min(360px, calc(100vw - 40px)); width: min(360px, calc(100vw - 40px));
border-radius: 24px; border-radius: 24px;
padding: 18px; padding: 14px 16px;
color: var(--text); color: var(--text);
pointer-events: auto; pointer-events: auto;
} }
@@ -172,7 +212,8 @@ canvas {
margin: 0; margin: 0;
color: var(--accent); color: var(--accent);
letter-spacing: 0.16em; letter-spacing: 0.16em;
font-size: 0.72rem; font-size: 0.64rem;
line-height: 1;
text-transform: uppercase; text-transform: uppercase;
} }
@@ -186,6 +227,20 @@ canvas {
white-space: pre-wrap; white-space: pre-wrap;
} }
.collapsible-panel.is-collapsed .topbar-body,
.collapsible-panel.is-collapsed .network-body,
.collapsible-panel.is-collapsed .performance-body {
display: none;
}
.collapsible-panel.is-collapsed .panel-summary {
display: inline-block;
}
.collapsible-panel.is-collapsed {
padding-bottom: 12px;
}
.detail-title { .detail-title {
margin-top: 12px; margin-top: 12px;
font-size: 1.05rem; font-size: 1.05rem;
@@ -208,6 +263,40 @@ canvas {
margin: 0 0 12px; margin: 0 0 12px;
} }
.detail-progress,
.ship-action-progress {
margin: 0 0 12px;
}
.detail-progress-label,
.ship-action-progress-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1;
}
.detail-progress-track,
.ship-action-progress-track {
height: 6px;
border-radius: 999px;
overflow: hidden;
background: rgba(127, 214, 255, 0.12);
border: 1px solid rgba(127, 214, 255, 0.14);
}
.detail-progress-fill,
.ship-action-progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
}
.history { .history {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace; font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.78rem; font-size: 0.78rem;
@@ -329,7 +418,7 @@ canvas {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 100vw; width: 50vw;
min-height: 128px; min-height: 128px;
border-radius: 0; border-radius: 0;
padding: 0; padding: 0;
@@ -412,12 +501,16 @@ canvas {
font-size: 0.72rem; font-size: 0.72rem;
} }
.ship-card-header + p { .ship-card-header+p {
font-size: 0.62rem; font-size: 0.62rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
} }
.ship-action-progress {
margin-top: 2px;
}
.ship-card-ai { .ship-card-ai {
margin-top: 2px; margin-top: 2px;
padding-top: 6px; padding-top: 6px;
@@ -495,7 +588,7 @@ canvas {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 100vw; width: 50vw;
min-height: 120px; min-height: 120px;
} }

View File

@@ -7,6 +7,7 @@ import type {
ClaimVisual, ClaimVisual,
ConstructionSiteVisual, ConstructionSiteVisual,
NodeVisual, NodeVisual,
PlanetVisual,
Selectable, Selectable,
ShipVisual, ShipVisual,
SpatialNodeVisual, SpatialNodeVisual,
@@ -19,7 +20,7 @@ interface ResolveSelectionPositionParams {
selection: Selectable; selection: Selectable;
worldTimeSyncMs: number; worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
@@ -49,7 +50,7 @@ interface SeedSystemFocusParams {
systemFocusLocal: THREE.Vector3; systemFocusLocal: THREE.Vector3;
worldTimeSyncMs: number; worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[]; planetVisuals: PlanetVisual[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3; computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined; resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3; resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
@@ -217,9 +218,7 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
return undefined; return undefined;
} }
const visual = planetVisuals.find((candidate) => return computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
candidate.systemId === selection.systemId && candidate.planet === planet);
return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
} }
const system = world.systems.get(selection.id); const system = world.systems.get(selection.id);
@@ -240,8 +239,14 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
return; return;
} }
if (selection.kind === "system") {
galaxyFocus.copy(nextFocus);
systemFocusLocal.set(0, 0, 0);
return;
}
const selectionSystemId = resolveSelectableSystemId(world, selection); const selectionSystemId = resolveSelectableSystemId(world, selection);
if (selectionSystemId && selection.kind !== "system" && world) { if (selectionSystemId && world) {
const system = world.systems.get(selectionSystemId); const system = world.systems.get(selectionSystemId);
if (system) { if (system) {
galaxyFocus.copy(toThreeVector(system.galaxyPosition)); galaxyFocus.copy(toThreeVector(system.galaxyPosition));
@@ -282,6 +287,11 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
const selected = selectedItems[0]; const selected = selectedItems[0];
if (selected && resolveSelectableSystemId(world, selected) === systemId) { if (selected && resolveSelectableSystemId(world, selected) === systemId) {
if (selected.kind === "system") {
systemFocusLocal.set(0, 0, 0);
return;
}
const selectedPosition = resolveSelectionPosition({ const selectedPosition = resolveSelectionPosition({
world, world,
selection: selected, selection: selected,

View File

@@ -9,7 +9,8 @@ import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
export function createViewerControllers(host: any) { export function createViewerControllers(host: any) {
const sceneDataController = new ViewerSceneDataController({ const sceneDataController = new ViewerSceneDataController({
documentRef: document, documentRef: document,
getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc, getWorldOrbitalTimeSeconds: () => host.world?.orbitalTimeSeconds,
getOrbitalSimulationSpeed: () => host.world?.orbitalSimulation.simulatedSecondsPerRealSecond ?? 0,
getWorldSeed: () => host.world?.seed ?? 1, getWorldSeed: () => host.world?.seed ?? 1,
getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getWorldPresentationContext: () => host.createWorldPresentationContext(), getWorldPresentationContext: () => host.createWorldPresentationContext(),
@@ -77,6 +78,9 @@ export function createViewerControllers(host: any) {
scene: host.scene, scene: host.scene,
camera: host.camera, camera: host.camera,
ambienceGroup: host.ambienceGroup, ambienceGroup: host.ambienceGroup,
gameSummaryEl: host.gameSummaryEl,
networkSummaryEl: host.networkSummaryEl,
performanceSummaryEl: host.performanceSummaryEl,
statusEl: host.statusEl, statusEl: host.statusEl,
networkPanelEl: host.networkPanelEl, networkPanelEl: host.networkPanelEl,
performancePanelEl: host.performancePanelEl, performancePanelEl: host.performancePanelEl,
@@ -90,6 +94,7 @@ export function createViewerControllers(host: any) {
getCameraMode: () => host.cameraMode, getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId, getCameraTargetShipId: () => host.cameraTargetShipId,
getZoomLevel: () => host.zoomLevel, getZoomLevel: () => host.zoomLevel,
getSelectedItems: () => host.selectedItems,
getWorldTimeSyncMs: () => host.worldTimeSyncMs, getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance, getCurrentDistance: () => host.currentDistance,
systemFocusLocal: host.systemFocusLocal, systemFocusLocal: host.systemFocusLocal,

View File

@@ -1,5 +1,6 @@
import * as THREE from "three"; import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants"; import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
import { rawObject } from "./viewerScenePrimitives";
import type { import type {
CameraMode, CameraMode,
Selectable, Selectable,
@@ -166,14 +167,15 @@ export function updateFollowCamera(params: {
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) { export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) {
for (const [systemId, visual] of systemVisuals.entries()) { for (const [systemId, visual] of systemVisuals.entries()) {
visual.detailGroup.visible = systemId === activeSystemId; visual.detailGroup.setVisible(systemId === activeSystemId);
} }
} }
export function setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { export function setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
sprite.visible = opacity > 0.02; sprite.setVisible(opacity > 0.02);
sprite.material.opacity = opacity; const material = (rawObject(sprite) as THREE.Sprite).material;
sprite.material.needsUpdate = true; material.opacity = opacity;
material.needsUpdate = true;
} }
export function zoomFromWheel(desiredDistance: number, deltaY: number) { export function zoomFromWheel(desiredDistance: number, deltaY: number) {
@@ -203,8 +205,17 @@ export function applyKeyboardControl(params: {
desiredDistance = ZOOM_DISTANCE.system; desiredDistance = ZOOM_DISTANCE.system;
} else if (key === "3") { } else if (key === "3") {
desiredDistance = ZOOM_DISTANCE.universe; desiredDistance = ZOOM_DISTANCE.universe;
} else if (key === "=") {
desiredDistance = desiredDistance <= ZOOM_DISTANCE.system
? ZOOM_DISTANCE.local
: ZOOM_DISTANCE.system;
} else if (key === "-") {
desiredDistance = desiredDistance >= ZOOM_DISTANCE.system
? ZOOM_DISTANCE.universe
: ZOOM_DISTANCE.system;
} else if (key === "/") {
desiredDistance = ZOOM_DISTANCE.system;
} }
return { cameraMode, desiredDistance }; return { cameraMode, desiredDistance };
} }

View File

@@ -1,22 +1,38 @@
import { inventoryAmount } from "./viewerMath"; import { inventoryAmount } from "./viewerMath";
import type { CameraMode, Selectable, WorldState } from "./viewerTypes"; import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
export function renderFactionStrip( export function renderFactionStrip(
world: WorldState | undefined, world: WorldState | undefined,
selectedItems: Selectable[], selectedItems: Selectable[],
cameraMode: CameraMode, cameraMode: CameraMode,
cameraTargetShipId?: string, cameraTargetShipId?: string,
zoomLevel?: ZoomLevel,
activeSystemId?: string,
) { ) {
if (!world) { if (!world) {
return ""; return "";
} }
const ships = [...world.ships.values()] const ships = [...world.ships.values()]
.filter((ship) => {
if (zoomLevel === "universe" || !activeSystemId) {
return true;
}
return ship.systemId === activeSystemId;
})
.sort((left, right) => left.label.localeCompare(right.label)); .sort((left, right) => left.label.localeCompare(right.label));
return ships return ships
.map((ship) => { .map((ship) => {
const fuel = inventoryAmount(ship.inventory, "gas"); const fuel = inventoryAmount(ship.inventory, "fuel");
const cargo = ship.cargoItemId
? inventoryAmount(ship.inventory, ship.cargoItemId)
: 0;
const shipLocation = describeShipLocation(world, ship);
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
const isSelected = selectedItems.length === 1 const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "ship" && selectedItems[0].kind === "ship"
&& selectedItems[0].id === ship.id; && selectedItems[0].id === ship.id;
@@ -37,9 +53,20 @@ export function renderFactionStrip(
>&#128340;</button> >&#128340;</button>
</div> </div>
</div> </div>
<p>${ship.systemId}</p> <p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p> <p>Fuel ${fuel.toFixed(1)} · Cap ${ship.energyStored.toFixed(1)}${ship.cargoCapacity > 0 ? ` · Cargo ${cargo.toFixed(0)}` : ""}</p>
<p>State ${ship.state}</p> <p>State ${shipState}</p>
${shipAction ? `
<div class="ship-action-progress">
<div class="ship-action-progress-label">
<span>${shipAction.label}</span>
<span>${Math.round(shipAction.progress * 100)}%</span>
</div>
<div class="ship-action-progress-track">
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
</div>
</div>
` : ""}
<div class="ship-card-ai"> <div class="ship-card-ai">
<p>Order ${ship.orderKind ?? "none"}</p> <p>Order ${ship.orderKind ?? "none"}</p>
<p>Behavior ${ship.defaultBehaviorKind}</p> <p>Behavior ${ship.defaultBehaviorKind}</p>

View File

@@ -1,13 +1,19 @@
export interface ViewerHudElements { export interface ViewerHudElements {
root: HTMLDivElement; root: HTMLDivElement;
gamePanelEl: HTMLDivElement;
statusEl: HTMLDivElement; statusEl: HTMLDivElement;
gameSummaryEl: HTMLSpanElement;
networkSectionEl: HTMLDivElement;
systemPanelEl: HTMLDivElement; systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement; systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement; systemBodyEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement; detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement; detailBodyEl: HTMLDivElement;
factionStripEl: HTMLDivElement; factionStripEl: HTMLDivElement;
networkSummaryEl: HTMLSpanElement;
networkPanelEl: HTMLDivElement; networkPanelEl: HTMLDivElement;
performanceSectionEl: HTMLDivElement;
performanceSummaryEl: HTMLSpanElement;
performancePanelEl: HTMLDivElement; performancePanelEl: HTMLDivElement;
errorEl: HTMLDivElement; errorEl: HTMLDivElement;
historyLayerEl: HTMLDivElement; historyLayerEl: HTMLDivElement;
@@ -20,16 +26,34 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
root.className = "viewer-shell"; root.className = "viewer-shell";
root.innerHTML = ` root.innerHTML = `
<div class="left-panel-stack"> <div class="left-panel-stack">
<header class="topbar"> <header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
<h2>Game</h2> <div class="panel-heading">
<h2>Game</h2>
<div class="panel-heading-meta">
<span class="panel-summary game-summary">Bootstrapping</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Game panel">+</button>
</div>
</div>
<div class="topbar-body">Bootstrapping</div> <div class="topbar-body">Bootstrapping</div>
</header> </header>
<aside class="network-panel"> <aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
<h2>Network</h2> <div class="panel-heading">
<h2>Network</h2>
<div class="panel-heading-meta">
<span class="panel-summary network-summary">Waiting</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Network panel">+</button>
</div>
</div>
<div class="network-body">Waiting for snapshot.</div> <div class="network-body">Waiting for snapshot.</div>
</aside> </aside>
<aside class="performance-panel"> <aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
<h2>Performance</h2> <div class="panel-heading">
<h2>Performance</h2>
<div class="panel-heading-meta">
<span class="panel-summary performance-summary">Waiting</span>
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Performance panel">+</button>
</div>
</div>
<div class="performance-body">Waiting for frame samples.</div> <div class="performance-body">Waiting for frame samples.</div>
</aside> </aside>
</div> </div>
@@ -54,14 +78,20 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
return { return {
root, root,
gamePanelEl: root.querySelector(".topbar") as HTMLDivElement,
statusEl: root.querySelector(".topbar-body") as HTMLDivElement, statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement,
networkSectionEl: root.querySelector(".network-panel") as HTMLDivElement,
systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement, systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement,
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement, systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement, systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement, detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement, detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement, factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement,
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement, networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement,
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement, performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
errorEl: root.querySelector(".error-strip") as HTMLDivElement, errorEl: root.querySelector(".error-strip") as HTMLDivElement,
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement, historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,

View File

@@ -140,6 +140,9 @@ export class ViewerInteractionController {
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
this.context.setSelectedItems(picked ? [picked] : []); this.context.setSelectedItems(picked ? [picked] : []);
if (picked && this.shouldFocusSelectionOnClick(picked)) {
this.context.focusOnSelection(picked);
}
this.context.syncFollowStateFromSelection(); this.context.syncFollowStateFromSelection();
this.context.updatePanels(); this.context.updatePanels();
}; };
@@ -294,4 +297,12 @@ export class ViewerInteractionController {
this.context.syncFollowStateFromSelection(); this.context.syncFollowStateFromSelection();
this.context.updatePanels(); this.context.updatePanels();
} }
private shouldFocusSelectionOnClick(selection: Selectable) {
if (selection.kind === "planet") {
return true;
}
return selection.kind === "system" && selection.id !== this.context.getActiveSystemId();
}
} }

View File

@@ -76,9 +76,8 @@ export function currentWorldTimeSeconds(world: WorldState | undefined, worldTime
return 0; return 0;
} }
const baseUtcMs = Date.parse(world.generatedAtUtc);
const elapsedMs = performance.now() - worldTimeSyncMs; const elapsedMs = performance.now() - worldTimeSyncMs;
return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97); return world.orbitalTimeSeconds + ((elapsedMs / 1000) * world.orbitalSimulation.simulatedSecondsPerRealSecond);
} }
export function hashUnit(seed: number, value: string): number { export function hashUnit(seed: number, value: string): number {

View File

@@ -1,5 +1,5 @@
import { formatInventory, formatVector } from "./viewerMath"; import { formatInventory, formatVector, inventoryAmount } from "./viewerMath";
import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection"; import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type { import type {
CameraMode, CameraMode,
HistoryWindowState, HistoryWindowState,
@@ -31,6 +31,29 @@ interface SystemPanelParams {
cameraTargetShipId?: string; cameraTargetShipId?: string;
} }
function renderSystemOwnership(world: WorldState, systemId: string): string {
const claims = [...world.claims.values()].filter((claim) =>
claim.systemId === systemId && claim.state !== "destroyed");
if (claims.length === 0) {
return "Ownership none";
}
const ownershipByFaction = new Map<string, number>();
for (const claim of claims) {
ownershipByFaction.set(claim.factionId, (ownershipByFaction.get(claim.factionId) ?? 0) + 1);
}
return [...ownershipByFaction.entries()]
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
.map(([factionId, count]) => {
const faction = world.factions.get(factionId);
const label = faction?.label ?? factionId;
const share = Math.round((count / claims.length) * 100);
return `${label} ${count}/${claims.length} (${share}%)`;
})
.join("<br>");
}
export function updateDetailPanel( export function updateDetailPanel(
detailTitleEl: HTMLHeadingElement, detailTitleEl: HTMLHeadingElement,
detailBodyEl: HTMLDivElement, detailBodyEl: HTMLDivElement,
@@ -79,12 +102,30 @@ export function updateDetailPanel(
return; return;
} }
const parent = describeSelectionParent(selected); const parent = describeSelectionParent(selected);
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); const fuelStored = inventoryAmount(ship.inventory, "fuel");
const cargoUsed = ship.cargoItemId
? inventoryAmount(ship.inventory, ship.cargoItemId)
: 0;
const cargoLabel = ship.cargoItemId ?? "none";
const shipState = describeShipState(world, ship);
const shipAction = describeShipCurrentAction(ship);
detailTitleEl.textContent = ship.label; detailTitleEl.textContent = ship.label;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
<p>Parent ${parent}</p> <p>Parent ${parent}</p>
<p>State ${ship.state}</p> <p>State ${shipState}</p>
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p> ${shipAction ? `
<div class="detail-progress">
<div class="detail-progress-label">
<span>${shipAction.label}</span>
<span>${Math.round(shipAction.progress * 100)}%</span>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
</div>
</div>
` : ""}
<p>Fuel ${fuelStored.toFixed(1)}<br>Capacitor ${ship.energyStored.toFixed(1)}</p>
<p>Cargo ${cargoLabel} ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>Inventory ${formatInventory(ship.inventory)}</p> <p>Inventory ${formatInventory(ship.inventory)}</p>
<p>Velocity ${formatVector(ship.localVelocity)}</p> <p>Velocity ${formatVector(ship.localVelocity)}</p>
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p> <p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
@@ -98,12 +139,43 @@ export function updateDetailPanel(
return; return;
} }
const parent = describeSelectionParent(selected); const parent = describeSelectionParent(selected);
const installedModules = station.installedModules.length > 0
? station.installedModules.join("<br>")
: "none";
const activeConstruction = [...world.constructionSites.values()]
.filter((site) => site.stationId === station.id && site.state !== "completed")
.map((site) => `${site.blueprintId ?? site.targetDefinitionId} (${site.state})`)
.join("<br>") || "none";
const dockedShipLabels = station.dockedShipIds.length > 0
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
: "none";
const stationInventory = station.inventory.filter((entry) => entry.itemId !== "fuel");
const stationProcesses = station.currentProcesses;
const stationProcessingHtml = stationProcesses.length > 0
? stationProcesses.map((process) => `
<div class="detail-progress">
<div class="detail-progress-label">
<span>${process.label}</span>
<span>${Math.round(process.progress * 100)}%</span>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(process.progress * 100).toFixed(1)}%"></div>
</div>
</div>
`).join("")
: "";
detailTitleEl.textContent = station.label; detailTitleEl.textContent = station.label;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p> <p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p> <p>Parent ${parent}</p>
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips} / ${station.dockingPads}</p> ${stationProcessingHtml}
<p>Inventory ${formatInventory(station.inventory)}</p> <p>Fuel ${station.fuelStored.toFixed(1)} / ${station.fuelCapacity.toFixed(1)}<br>Capacitor ${station.energyStored.toFixed(1)} / ${station.energyCapacity.toFixed(1)}</p>
<p>Docked ${station.dockedShips} / ${station.dockingPads}
<br>
${dockedShipLabels}</p>
<p>Modules ${installedModules}</p>
<p>Constructing ${activeConstruction}</p>
<p>Inventory ${formatInventory(stationInventory)}</p>
<p>History available in the separate history window.</p> <p>History available in the separate history window.</p>
`; `;
return; return;
@@ -115,11 +187,23 @@ export function updateDetailPanel(
return; return;
} }
const parent = describeSelectionParent(selected); const parent = describeSelectionParent(selected);
const nodeLevel = node.maxOre > 0
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
: 0;
detailTitleEl.textContent = `Node ${node.id}`; detailTitleEl.textContent = `Node ${node.id}`;
detailBodyEl.innerHTML = ` detailBodyEl.innerHTML = `
<p>${node.systemId}</p> <p>${node.systemId}</p>
<p>Parent ${parent}</p> <p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p> <p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<div class="detail-progress">
<div class="detail-progress-label">
<span>Level</span>
<span>${Math.round(nodeLevel * 100)}%</span>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
</div>
</div>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p> <p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`; `;
return; return;
@@ -240,7 +324,9 @@ export function updateSystemPanel(params: SystemPanelParams) {
} }
systemTitleEl.textContent = activeSystem.label; systemTitleEl.textContent = activeSystem.label;
systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId); systemBodyEl.innerHTML = `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`;
} }
export function describeSelectionParent( export function describeSelectionParent(
@@ -270,8 +356,13 @@ export function describeSelectionParent(
} }
if (selection.kind === "station") { if (selection.kind === "station") {
const station = world.stations.get(selection.id); const station = world.stations.get(selection.id);
const visual = station ? stationVisuals.get(selection.id) : undefined; if (!station) {
return describeOrbitalParent(world, station?.systemId, visual?.anchor); return "unknown";
}
return station.anchorNodeId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network`
: "unknown";
} }
if (selection.kind === "node") { if (selection.kind === "node") {
const node = world.nodes.get(selection.id); const node = world.nodes.get(selection.id);

View File

@@ -1,6 +1,7 @@
import * as THREE from "three"; import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants"; import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath"; import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath";
import { rawObject } from "./viewerScenePrimitives";
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes"; import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) { export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
@@ -40,19 +41,21 @@ export function updatePlanetPresentation(
? localPosition.clone().sub(systemFocusLocal).multiplyScalar(scale) ? localPosition.clone().sub(systemFocusLocal).multiplyScalar(scale)
: localPosition.multiplyScalar(scale); : localPosition.multiplyScalar(scale);
visual.orbit.scale.setScalar(scale); visual.orbit.setScaleScalar(scale);
visual.orbit.position.copy(orbitOffset); visual.orbit.setPosition(orbitOffset);
visual.mesh.position.copy(position); visual.mesh.setPosition(position);
visual.icon.position.copy(position); visual.icon.setPosition(position);
if (visual.ring) { if (visual.ring) {
visual.ring.position.copy(position); visual.ring.setPosition(position);
} }
for (const [moonIndex, moon] of visual.moons.entries()) { for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.position.copy(position); moon.orbit.setPosition(position);
moon.orbit.scale.setScalar(scale); moon.orbit.setScaleScalar(scale);
moon.mesh.position.copy(position).add( moon.mesh.setPosition(
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale), position.clone().add(
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale),
),
); );
} }
} }
@@ -69,7 +72,7 @@ export function updateSystemSummaryPresentation(
const distance = camera.position.distanceTo(worldPosition); const distance = camera.position.distanceTo(worldPosition);
const minimumScale = activeSystemId && systemId !== activeSystemId ? 1200 : 1400; const minimumScale = activeSystemId && systemId !== activeSystemId ? 1200 : 1400;
const scale = Math.max(minimumScale, distance * distanceScale); const scale = Math.max(minimumScale, distance * distanceScale);
visual.sprite.scale.set(scale, scale * 0.3125, 1); rawObject(visual.sprite).scale.set(scale, scale * 0.3125, 1);
} }
} }
@@ -78,49 +81,49 @@ export function updateSystemStarPresentation(
activeSystemId: string | undefined, activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3, systemFocusLocal: THREE.Vector3,
camera: THREE.PerspectiveCamera, camera: THREE.PerspectiveCamera,
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void, setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void,
) { ) {
const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined; const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined;
for (const [systemId, visual] of systemVisuals.entries()) { for (const [systemId, visual] of systemVisuals.entries()) {
visual.root.position.copy(visual.galaxyPosition); visual.root.setPosition(visual.galaxyPosition);
visual.shellReticle.scale.setScalar(visual.shellReticleBaseScale); visual.shellReticle.setScaleScalar(visual.shellReticleBaseScale);
if (!activeSystem) { if (!activeSystem) {
visual.starCluster.position.set(0, 0, 0); visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.position.set(0, 0, 0); visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.visible = true; visual.icon.setVisible(true);
visual.shellReticle.position.set(0, 0, 0); visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
visual.shellReticle.visible = false; visual.shellReticle.setVisible(false);
setShellReticleOpacity(visual.shellReticle, 0); setShellReticleOpacity(visual.shellReticle, 0);
continue; continue;
} }
if (systemId !== activeSystemId) { if (systemId !== activeSystemId) {
visual.starCluster.position.set(0, 0, 0); visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.position.set(0, 0, 0); visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
visual.icon.visible = false; visual.icon.setVisible(false);
visual.shellReticle.position.set(0, 0, 0); visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
visual.shellReticle.visible = true; visual.shellReticle.setVisible(true);
setShellReticleOpacity(visual.shellReticle, 1); setShellReticleOpacity(visual.shellReticle, 1);
const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition);
if (direction.lengthSq() > 0.0001) { if (direction.lengthSq() > 0.0001) {
visual.root.position.copy( visual.root.setPosition(
activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)), activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)),
); );
} }
const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3()); const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3());
const reticleDistance = camera.position.distanceTo(reticleWorldPosition); const reticleDistance = camera.position.distanceTo(reticleWorldPosition);
const reticleScale = Math.max(900, reticleDistance * 0.032); const reticleScale = Math.max(900, reticleDistance * 0.032);
visual.shellReticle.scale.setScalar(reticleScale); visual.shellReticle.setScaleScalar(reticleScale);
continue; continue;
} }
const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE); const offset = systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE);
visual.starCluster.position.copy(offset); visual.starCluster.setPosition(offset);
visual.icon.position.copy(offset); visual.icon.setPosition(offset);
visual.icon.visible = true; visual.icon.setVisible(true);
visual.shellReticle.visible = false; visual.shellReticle.setVisible(false);
setShellReticleOpacity(visual.shellReticle, 0); setShellReticleOpacity(visual.shellReticle, 0);
} }
} }

View File

@@ -3,18 +3,24 @@ import { computeZoomBlend } from "./viewerMath";
import { import {
updateNetworkPanel as renderNetworkPanel, updateNetworkPanel as renderNetworkPanel,
recordPerformanceStats, recordPerformanceStats,
summarizeNetworkStats,
summarizePerformanceStats,
updatePerformancePanel as renderPerformancePanel, updatePerformancePanel as renderPerformancePanel,
} from "./viewerTelemetry"; } from "./viewerTelemetry";
import { updatePlanetPresentation } from "./viewerPresentation"; import { updatePlanetPresentation } from "./viewerPresentation";
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation"; import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
import { updateSystemPanel } from "./viewerPanels"; import { updateSystemPanel } from "./viewerPanels";
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory"; import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
import type { OrbitLineVisual, Selectable } from "./viewerTypes";
export interface ViewerPresentationContext { export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer; renderer: THREE.WebGLRenderer;
scene: THREE.Scene; scene: THREE.Scene;
camera: THREE.PerspectiveCamera; camera: THREE.PerspectiveCamera;
ambienceGroup: THREE.Group; ambienceGroup: THREE.Group;
gameSummaryEl: HTMLSpanElement;
networkSummaryEl: HTMLSpanElement;
performanceSummaryEl: HTMLSpanElement;
statusEl: HTMLDivElement; statusEl: HTMLDivElement;
networkPanelEl: HTMLDivElement; networkPanelEl: HTMLDivElement;
performancePanelEl: HTMLDivElement; performancePanelEl: HTMLDivElement;
@@ -28,13 +34,14 @@ export interface ViewerPresentationContext {
getCameraMode: () => any; getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined; getCameraTargetShipId: () => string | undefined;
getZoomLevel: () => any; getZoomLevel: () => any;
getSelectedItems: () => Selectable[];
getWorldTimeSyncMs: () => number; getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number; getCurrentDistance: () => number;
systemFocusLocal: THREE.Vector3; systemFocusLocal: THREE.Vector3;
planetVisuals: any[]; planetVisuals: any[];
systemSummaryVisuals: Map<any, any>; systemSummaryVisuals: Map<any, any>;
presentationEntries: any[]; presentationEntries: any[];
orbitLines: THREE.Object3D[]; orbitLines: OrbitLineVisual[];
systemVisuals: Map<any, any>; systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any; createWorldPresentationContext: () => any;
} }
@@ -74,20 +81,20 @@ export class ViewerPresentationController {
? blend.systemWeight * (isActiveDetail ? 1 : 0) ? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight); : Math.max(blend.systemWeight, blend.universeWeight);
this.setObjectOpacity(entry.detail, detailAlpha); entry.detail.setOpacity(detailAlpha);
this.setObjectOpacity(entry.icon, iconAlpha); entry.icon.setOpacity(iconAlpha);
} }
for (const orbitLine of this.context.orbitLines) { for (const orbitLine of this.context.orbitLines) {
const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (activeSystemId ? 1 : 0); const alpha = this.resolveOrbitLineOpacity(orbitLine, blend, activeSystemId);
this.setObjectOpacity(orbitLine, alpha); orbitLine.line.setOpacity(alpha);
} }
for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) { for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) {
const summaryOpacity = systemId === activeSystemId const summaryOpacity = systemId === activeSystemId
? 0 ? 0
: (activeSystemId ? 0.72 : 0.96); : (activeSystemId ? 0.72 : 0.96);
this.setObjectOpacity(summaryVisual.sprite, summaryOpacity); summaryVisual.sprite.setOpacity(summaryOpacity);
} }
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035); this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
@@ -95,6 +102,7 @@ export class ViewerPresentationController {
updateNetworkPanel() { updateNetworkPanel() {
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats); renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats);
} }
recordPerformanceStats(frameMs: number) { recordPerformanceStats(frameMs: number) {
@@ -103,6 +111,7 @@ export class ViewerPresentationController {
updatePerformancePanel() { updatePerformancePanel() {
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer); renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats);
} }
updateShipPresentation() { updateShipPresentation() {
@@ -131,10 +140,12 @@ export class ViewerPresentationController {
updateGamePanel(mode: string) { updateGamePanel(mode: string) {
updateGameStatus({ updateGameStatus({
statusEl: this.context.statusEl, statusEl: this.context.statusEl,
summaryEl: this.context.gameSummaryEl,
world: this.context.getWorld(), world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(), activeSystemId: this.context.getActiveSystemId(),
cameraMode: this.context.getCameraMode(), cameraMode: this.context.getCameraMode(),
zoomLevel: this.context.getZoomLevel(), zoomLevel: this.context.getZoomLevel(),
selectedItems: this.context.getSelectedItems(),
mode, mode,
}); });
} }
@@ -161,22 +172,21 @@ export class ViewerPresentationController {
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top); return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
} }
private setObjectOpacity(object: THREE.Object3D, opacity: number) { private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, blend: ReturnType<typeof computeZoomBlend>, activeSystemId?: string) {
const visible = opacity > 0.02; if (!activeSystemId || orbitLine.systemId !== activeSystemId) {
object.visible = visible; return 0;
object.traverse((child) => { }
if (!("material" in child)) {
return; const selected = this.context.getSelectedItems();
} const selectedItem = selected.length === 1 ? selected[0] : undefined;
const materials = Array.isArray(child.material) ? child.material : [child.material]; const baseAlpha = Math.max(blend.localWeight * 0.55, blend.systemWeight);
for (const material of materials) {
if (!("opacity" in material)) { if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
continue; return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex
} ? baseAlpha
material.transparent = true; : 0;
material.opacity = opacity; }
material.needsUpdate = true;
} return orbitLine.kind === "planet" ? baseAlpha : 0;
});
} }
} }

View File

@@ -48,13 +48,13 @@ import type {
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
} from "./contracts"; } from "./contracts";
import type { import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes";
OrbitalAnchor, import type { SceneNode } from "./viewerScenePrimitives";
} from "./viewerTypes";
export interface ViewerSceneDataContext { export interface ViewerSceneDataContext {
documentRef: Document; documentRef: Document;
getWorldGeneratedAtUtc: () => string | undefined; getWorldOrbitalTimeSeconds: () => number | undefined;
getOrbitalSimulationSpeed: () => number;
getWorldSeed: () => number; getWorldSeed: () => number;
getWorldTimeSyncMs: () => number; getWorldTimeSyncMs: () => number;
getWorldPresentationContext: () => any; getWorldPresentationContext: () => any;
@@ -71,7 +71,7 @@ export interface ViewerSceneDataContext {
systemVisuals: Map<any, any>; systemVisuals: Map<any, any>;
systemSummaryVisuals: Map<any, any>; systemSummaryVisuals: Map<any, any>;
planetVisuals: any[]; planetVisuals: any[];
orbitLines: THREE.Object3D[]; orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<any, any>; spatialNodeVisuals: Map<any, any>;
bubbleVisuals: Map<any, any>; bubbleVisuals: Map<any, any>;
nodeVisuals: Map<any, any>; nodeVisuals: Map<any, any>;
@@ -79,7 +79,7 @@ export interface ViewerSceneDataContext {
claimVisuals: Map<any, any>; claimVisuals: Map<any, any>;
constructionSiteVisuals: Map<any, any>; constructionSiteVisuals: Map<any, any>;
shipVisuals: Map<any, any>; shipVisuals: Map<any, any>;
registerPresentation: (detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void; registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
} }
export class ViewerSceneDataController { export class ViewerSceneDataController {
@@ -153,7 +153,7 @@ export class ViewerSceneDataController {
systemFocusLocal: THREE.Vector3; systemFocusLocal: THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void; updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void; setShellReticleOpacity: (sprite: any, opacity: number) => void;
}) { }) {
return { return {
world: overrides.world, world: overrides.world,
@@ -181,7 +181,8 @@ export class ViewerSceneDataController {
private createSceneSyncContext() { private createSceneSyncContext() {
return { return {
documentRef: this.context.documentRef, documentRef: this.context.documentRef,
worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(), worldOrbitalTimeSeconds: this.context.getWorldOrbitalTimeSeconds(),
orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(),
worldSeed: this.context.getWorldSeed(), worldSeed: this.context.getWorldSeed(),
worldTimeSyncMs: this.context.getWorldTimeSyncMs(), worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
systemGroup: this.context.systemGroup, systemGroup: this.context.systemGroup,

View File

@@ -24,8 +24,10 @@ import {
starHaloOpacity, starHaloOpacity,
toThreeVector, toThreeVector,
} from "./viewerMath"; } from "./viewerMath";
import { createSceneNode } from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives";
export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh { export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas"; const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0), isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0),
@@ -39,12 +41,12 @@ export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
); );
mesh.position.copy(toThreeVector(node.localPosition)); mesh.position.copy(toThreeVector(node.localPosition));
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
return mesh; return createSceneNode(mesh);
} }
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh { export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode {
const color = spatialNodeColor(node.kind); const color = spatialNodeColor(node.kind);
return new THREE.Mesh( return createSceneNode(new THREE.Mesh(
new THREE.OctahedronGeometry(10, 0), new THREE.OctahedronGeometry(10, 0),
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color, color,
@@ -52,14 +54,14 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo
roughness: 0.35, roughness: 0.35,
metalness: 0.45, metalness: 0.45,
}), }),
); ));
} }
export function createBubbleRing( export function createBubbleRing(
bubble: LocalBubbleSnapshot, bubble: LocalBubbleSnapshot,
localPosition: THREE.Vector3, localPosition: THREE.Vector3,
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[], createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
): THREE.LineLoop { ): SceneNode {
const ring = new THREE.LineLoop( const ring = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)), new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)),
new THREE.LineBasicMaterial({ new THREE.LineBasicMaterial({
@@ -69,11 +71,11 @@ export function createBubbleRing(
}), }),
); );
ring.position.copy(localPosition); ring.position.copy(localPosition);
return ring; return createSceneNode(ring);
} }
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh { export function createClaimMesh(claim: ClaimSnapshot): SceneNode {
return new THREE.Mesh( return createSceneNode(new THREE.Mesh(
new THREE.ConeGeometry(9, 20, 4), new THREE.ConeGeometry(9, 20, 4),
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color: claim.state === "active" ? 0xff7f50 : 0xff5b5b, color: claim.state === "active" ? 0xff7f50 : 0xff5b5b,
@@ -81,11 +83,11 @@ export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
roughness: 0.4, roughness: 0.4,
metalness: 0.28, metalness: 0.28,
}), }),
); ));
} }
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh { export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): SceneNode {
return new THREE.Mesh( return createSceneNode(new THREE.Mesh(
new THREE.TorusKnotGeometry(7, 2.2, 54, 8), new THREE.TorusKnotGeometry(7, 2.2, 54, 8),
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color: site.state === "completed" ? 0x46d37f : 0x9df29c, color: site.state === "completed" ? 0x46d37f : 0x9df29c,
@@ -93,10 +95,10 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THRE
roughness: 0.34, roughness: 0.34,
metalness: 0.48, metalness: 0.48,
}), }),
); ));
} }
export function createStarCluster(system: SystemSnapshot): THREE.Group { export function createStarCluster(system: SystemSnapshot): SceneNode {
const root = new THREE.Group(); const root = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const offsets = system.starCount > 1 const offsets = system.starCount > 1
@@ -123,22 +125,22 @@ export function createStarCluster(system: SystemSnapshot): THREE.Group {
root.add(star, halo); root.add(star, halo);
} }
return root; return createSceneNode(root);
} }
export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop { export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
const points = Array.from({ length: 120 }, (_, index) => { const points = Array.from({ length: 120 }, (_, index) => {
const phaseDegrees = (index / 120) * 360; const phaseDegrees = (index / 120) * 360;
return computePlanetLocalPosition(planet, 0, phaseDegrees); return computePlanetLocalPosition(planet, 0, phaseDegrees);
}); });
return new THREE.LineLoop( return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points), new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }), new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
); ));
} }
export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh { export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
const ring = new THREE.Mesh( const ring = new THREE.Mesh(
new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48), new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48),
@@ -151,7 +153,7 @@ export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh {
); );
ring.rotation.x = Math.PI / 2; ring.rotation.x = Math.PI / 2;
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25); ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
return ring; return createSceneNode(ring);
} }
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] { export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
@@ -185,23 +187,23 @@ export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVis
}), }),
); );
moons.push({ mesh, orbit }); moons.push({ systemId: "", planetIndex: -1, mesh: createSceneNode(mesh), orbit: createSceneNode(orbit) });
} }
return moons; return moons;
} }
export function createStationMesh(station: StationSnapshot): THREE.Mesh { export function createStationMesh(station: StationSnapshot): SceneNode {
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10), new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
); );
mesh.rotation.x = Math.PI / 2; mesh.rotation.x = Math.PI / 2;
mesh.position.copy(toThreeVector(station.localPosition)); mesh.position.copy(toThreeVector(station.localPosition));
return mesh; return createSceneNode(mesh);
} }
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh { export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
const geometry = new THREE.ConeGeometry(size, length, 7); const geometry = new THREE.ConeGeometry(size, length, 7);
geometry.rotateX(Math.PI / 2); geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh( const mesh = new THREE.Mesh(
@@ -212,7 +214,7 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number,
}), }),
); );
mesh.position.copy(toThreeVector(ship.localPosition)); mesh.position.copy(toThreeVector(ship.localPosition));
return mesh; return createSceneNode(mesh);
} }
export function createBackdropStars(): THREE.Points { export function createBackdropStars(): THREE.Points {
@@ -324,7 +326,7 @@ export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
}); });
} }
export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite { export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
const canvas = documentRef.createElement("canvas"); const canvas = documentRef.createElement("canvas");
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
@@ -356,7 +358,7 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
})); }));
sprite.scale.setScalar(size); sprite.scale.setScalar(size);
sprite.visible = false; sprite.visible = false;
return sprite; return createSceneNode(sprite);
} }
export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual { export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual {
@@ -364,18 +366,18 @@ export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.V
canvas.width = 512; canvas.width = 512;
canvas.height = 160; canvas.height = 160;
const texture = new THREE.CanvasTexture(canvas); const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ const sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({
map: texture, map: texture,
transparent: true, transparent: true,
depthWrite: false, depthWrite: false,
depthTest: false, depthTest: false,
})); })));
sprite.scale.set(520, 160, 1); sprite.object.scale.set(520, 160, 1);
sprite.visible = false; sprite.setVisible(false);
return { sprite, texture, anchor }; return { sprite, texture, anchor };
} }
export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite { export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode {
const canvas = documentRef.createElement("canvas"); const canvas = documentRef.createElement("canvas");
canvas.width = 128; canvas.width = 128;
canvas.height = 128; canvas.height = 128;
@@ -412,9 +414,9 @@ export function createShellReticle(documentRef: Document, color: string, size: n
blending: THREE.AdditiveBlending, blending: THREE.AdditiveBlending,
fog: false, fog: false,
}); });
const sprite = new THREE.Sprite(material); const sprite = createSceneNode(new THREE.Sprite(material));
sprite.scale.setScalar(size); sprite.setScaleScalar(size);
sprite.visible = false; sprite.setVisible(false);
sprite.renderOrder = 1000; sprite.setRenderOrder(1000);
return sprite; return sprite;
} }

View File

@@ -0,0 +1,159 @@
import * as THREE from "three";
export interface SceneNode {
readonly object: THREE.Object3D;
setPosition(position: THREE.Vector3): void;
setVisible(visible: boolean): void;
setScaleScalar(scale: number): void;
setRotationX(radians: number): void;
setRotationY(radians: number): void;
setRotationZ(radians: number): void;
setRenderOrder(order: number): void;
add(...children: SceneNode[]): void;
clear(): void;
lookAt(target: THREE.Vector3): void;
getWorldPosition(target?: THREE.Vector3): THREE.Vector3;
traverse(visitor: (child: THREE.Object3D) => void): void;
setOpacity(opacity: number): void;
setColor(color: THREE.ColorRepresentation): void;
setEmissive(color: THREE.ColorRepresentation, intensity?: number): void;
}
class ThreeSceneNode implements SceneNode {
constructor(public readonly object: THREE.Object3D) {}
setPosition(position: THREE.Vector3) {
this.object.position.copy(position);
}
setVisible(visible: boolean) {
this.object.visible = visible;
}
setScaleScalar(scale: number) {
this.object.scale.setScalar(scale);
}
setRotationX(radians: number) {
this.object.rotation.x = radians;
}
setRotationY(radians: number) {
this.object.rotation.y = radians;
}
setRotationZ(radians: number) {
this.object.rotation.z = radians;
}
setRenderOrder(order: number) {
this.object.renderOrder = order;
}
add(...children: SceneNode[]) {
this.object.add(...children.map((child) => child.object));
}
clear() {
if ("clear" in this.object && typeof this.object.clear === "function") {
this.object.clear();
}
}
lookAt(target: THREE.Vector3) {
this.object.lookAt(target);
}
getWorldPosition(target = new THREE.Vector3()) {
return this.object.getWorldPosition(target);
}
traverse(visitor: (child: THREE.Object3D) => void) {
this.object.traverse(visitor);
}
setOpacity(opacity: number) {
const visible = opacity > 0.02;
this.object.visible = visible;
this.object.traverse((child) => {
if (!("material" in child)) {
return;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if (!("opacity" in material)) {
continue;
}
material.transparent = true;
material.opacity = opacity;
material.needsUpdate = true;
}
});
}
setColor(color: THREE.ColorRepresentation) {
this.object.traverse((child) => {
if (!("material" in child)) {
return;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if ("color" in material) {
material.color.set(color);
material.needsUpdate = true;
}
}
});
}
setEmissive(color: THREE.ColorRepresentation, intensity = 1) {
this.object.traverse((child) => {
if (!("material" in child)) {
return;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if ("emissive" in material) {
material.emissive.set(color);
if ("emissiveIntensity" in material) {
material.emissiveIntensity = intensity;
}
material.needsUpdate = true;
}
}
});
}
}
export function createSceneNode<T extends THREE.Object3D>(object: T): SceneNode {
return new ThreeSceneNode(object);
}
export function rawObject(node: SceneNode) {
return node.object;
}
export function addToRawScene(scene: THREE.Scene, ...nodes: SceneNode[]) {
scene.add(...nodes.map((node) => node.object));
}
export function registerSelectableTarget(
selectableTargets: Map<THREE.Object3D, unknown>,
node: SceneNode,
selectable: unknown,
) {
selectableTargets.set(node.object, selectable);
}
export function registerSelectableDescendants(
selectableTargets: Map<THREE.Object3D, unknown>,
node: SceneNode,
selectable: unknown,
predicate: (child: THREE.Object3D) => boolean,
) {
node.traverse((child) => {
if (predicate(child)) {
selectableTargets.set(child, selectable);
}
});
}

View File

@@ -8,6 +8,7 @@ import type {
ClaimVisual, ClaimVisual,
ConstructionSiteVisual, ConstructionSiteVisual,
NodeVisual, NodeVisual,
OrbitLineVisual,
PlanetVisual, PlanetVisual,
PresentationEntry, PresentationEntry,
Selectable, Selectable,
@@ -39,6 +40,7 @@ import {
computePlanetLocalPosition, computePlanetLocalPosition,
toThreeVector, toThreeVector,
} from "./viewerMath"; } from "./viewerMath";
import { getAnimatedShipLocalPosition } from "./viewerPresentation";
import { import {
createBubbleRing, createBubbleRing,
createClaimMesh, createClaimMesh,
@@ -55,10 +57,18 @@ import {
createSystemSummaryVisual, createSystemSummaryVisual,
createTacticalIcon, createTacticalIcon,
} from "./viewerSceneFactory"; } from "./viewerSceneFactory";
import {
createSceneNode,
rawObject,
registerSelectableDescendants,
registerSelectableTarget,
} from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives";
interface SceneSyncContext { interface SceneSyncContext {
documentRef: Document; documentRef: Document;
worldGeneratedAtUtc?: string; worldOrbitalTimeSeconds?: number;
orbitalSimulationSpeed: number;
worldSeed: number; worldSeed: number;
worldTimeSyncMs: number; worldTimeSyncMs: number;
systemGroup: THREE.Group; systemGroup: THREE.Group;
@@ -74,7 +84,7 @@ interface SceneSyncContext {
systemVisuals: Map<string, SystemVisual>; systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>; systemSummaryVisuals: Map<string, SystemSummaryVisual>;
planetVisuals: PlanetVisual[]; planetVisuals: PlanetVisual[];
orbitLines: THREE.Object3D[]; orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<string, SpatialNodeVisual>; spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>; bubbleVisuals: Map<string, BubbleVisual>;
nodeVisuals: Map<string, NodeVisual>; nodeVisuals: Map<string, NodeVisual>;
@@ -83,8 +93,8 @@ interface SceneSyncContext {
constructionSiteVisuals: Map<string, ConstructionSiteVisual>; constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
shipVisuals: Map<string, ShipVisual>; shipVisuals: Map<string, ShipVisual>;
registerPresentation: ( registerPresentation: (
detail: THREE.Object3D, detail: SceneNode,
icon: THREE.Sprite, icon: SceneNode,
hideDetailInUniverse: boolean, hideDetailInUniverse: boolean,
hideIconInUniverse?: boolean, hideIconInUniverse?: boolean,
systemId?: string, systemId?: string,
@@ -111,8 +121,8 @@ interface SceneSyncContext {
} }
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) { export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
const worldTimeSeconds = context.worldGeneratedAtUtc const worldTimeSeconds = context.worldOrbitalTimeSeconds !== undefined
? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97) ? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed)
: 0; : 0;
context.systemGroup.clear(); context.systemGroup.clear();
@@ -124,9 +134,9 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
context.systemSummaryVisuals.clear(); context.systemSummaryVisuals.clear();
for (const system of systems) { for (const system of systems) {
const root = new THREE.Group(); const root = createSceneNode(new THREE.Group());
root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z); root.setPosition(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z));
const detailGroup = new THREE.Group(); const detailGroup = createSceneNode(new THREE.Group());
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02); const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const starCluster = createStarCluster(system); const starCluster = createStarCluster(system);
@@ -136,7 +146,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
context.documentRef, context.documentRef,
new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z), new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z),
); );
summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0); summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0));
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
context.registerPresentation(starCluster, systemIcon, true); context.registerPresentation(starCluster, systemIcon, true);
context.systemVisuals.set(system.id, { context.systemVisuals.set(system.id, {
@@ -150,18 +160,14 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
galaxyPosition: toThreeVector(system.galaxyPosition), galaxyPosition: toThreeVector(system.galaxyPosition),
}); });
context.systemSummaryVisuals.set(system.id, summaryVisual); context.systemSummaryVisuals.set(system.id, summaryVisual);
starCluster.traverse((child) => { registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh);
if (child instanceof THREE.Mesh) { registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id });
context.selectableTargets.set(child, { kind: "system", id: system.id }); registerSelectableTarget(context.selectableTargets, shellReticle, { kind: "system", id: system.id });
}
});
context.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
context.selectableTargets.set(shellReticle, { kind: "system", id: system.id });
for (const [planetIndex, planet] of system.planets.entries()) { for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet); const orbit = createPlanetOrbit(planet);
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06); const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
const planetMesh = new THREE.Mesh( const planetMesh = createSceneNode(new THREE.Mesh(
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18), new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
new THREE.MeshStandardMaterial({ new THREE.MeshStandardMaterial({
color: planet.color, color: planet.color,
@@ -169,13 +175,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
metalness: 0.08, metalness: 0.08,
emissive: new THREE.Color(planet.color).multiplyScalar(0.04), emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
}), }),
); ));
planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds)); planetMesh.setPosition(computePlanetLocalPosition(planet, worldTimeSeconds));
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2)); const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
planetIcon.position.copy(planetMesh.position); planetIcon.setPosition(rawObject(planetMesh).position.clone());
const ring = planet.hasRing ? createPlanetRing(planet) : undefined; const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) { if (ring) {
ring.position.copy(planetMesh.position); ring.setPosition(rawObject(planetMesh).position.clone());
} }
const moons = createMoonVisuals(planet, context.worldSeed); const moons = createMoonVisuals(planet, context.worldSeed);
detailGroup.add(orbit, planetMesh, planetIcon); detailGroup.add(orbit, planetMesh, planetIcon);
@@ -183,23 +189,35 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
detailGroup.add(ring); detailGroup.add(ring);
} }
for (const moon of moons) { for (const moon of moons) {
moon.orbit.position.copy(planetMesh.position); moon.systemId = system.id;
moon.mesh.position.copy(planetMesh.position); moon.planetIndex = planetIndex;
moon.orbit.setPosition(rawObject(planetMesh).position.clone());
moon.mesh.setPosition(rawObject(planetMesh).position.clone());
detailGroup.add(moon.orbit, moon.mesh); detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push(moon.orbit); context.orbitLines.push({
line: moon.orbit,
systemId: system.id,
kind: "moon",
planetIndex,
});
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id); context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
} }
context.orbitLines.push(orbit); context.orbitLines.push({
line: orbit,
systemId: system.id,
kind: "planet",
planetIndex,
});
context.registerPresentation(planetMesh, planetIcon, true, true, system.id); context.registerPresentation(planetMesh, planetIcon, true, true, system.id);
if (ring) { if (ring) {
context.registerPresentation(ring, planetIcon, true, true, system.id); context.registerPresentation(ring, planetIcon, true, true, system.id);
} }
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons }); context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
context.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex }); registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
context.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex }); registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
} }
context.systemGroup.add(root); context.systemGroup.add(rawObject(root));
} }
} }
@@ -211,8 +229,8 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
const mesh = createSpatialNodeMesh(node, context.spatialNodeColor); const mesh = createSpatialNodeMesh(node, context.spatialNodeColor);
const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18); const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18);
const localPosition = toThreeVector(node.localPosition); const localPosition = toThreeVector(node.localPosition);
mesh.position.copy(localPosition); mesh.setPosition(localPosition);
icon.position.copy(localPosition); icon.setPosition(localPosition);
context.spatialNodeVisuals.set(node.id, { context.spatialNodeVisuals.set(node.id, {
id: node.id, id: node.id,
systemId: node.systemId, systemId: node.systemId,
@@ -221,10 +239,10 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
kind: node.kind, kind: node.kind,
localPosition, localPosition,
}); });
context.spatialNodeGroup.add(mesh, icon); context.spatialNodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId); context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "spatial-node", id: node.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id });
context.selectableTargets.set(icon, { kind: "spatial-node", id: node.id }); registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id });
} }
} }
@@ -238,8 +256,8 @@ export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubble
const visual = { id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius }; const visual = { id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius };
context.setBubbleVisualState(visual, bubble); context.setBubbleVisualState(visual, bubble);
context.bubbleVisuals.set(bubble.id, visual); context.bubbleVisuals.set(bubble.id, visual);
context.bubbleGroup.add(mesh); context.bubbleGroup.add(rawObject(mesh));
context.selectableTargets.set(mesh, { kind: "bubble", id: bubble.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "bubble", id: bubble.id });
} }
} }
@@ -250,7 +268,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
for (const node of nodes) { for (const node of nodes) {
const mesh = createNodeMesh(node); const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
icon.position.copy(mesh.position); icon.setPosition(rawObject(mesh).position.clone());
const localPosition = toThreeVector(node.localPosition); const localPosition = toThreeVector(node.localPosition);
const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition); const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
const orbital = context.deriveNodeOrbital(node, anchor); const orbital = context.deriveNodeOrbital(node, anchor);
@@ -265,10 +283,10 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
orbitPhase: orbital.phase, orbitPhase: orbital.phase,
orbitInclination: orbital.inclination, orbitInclination: orbital.inclination,
}); });
context.nodeGroup.add(mesh, icon); context.nodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId); context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "node", id: node.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id });
context.selectableTargets.set(icon, { kind: "node", id: node.id }); registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id });
} }
} }
@@ -279,7 +297,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
for (const station of stations) { for (const station of stations) {
const mesh = createStationMesh(station); const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 26); const icon = createTacticalIcon(context.documentRef, station.color, 26);
icon.position.copy(mesh.position); icon.setPosition(rawObject(mesh).position.clone());
const localPosition = toThreeVector(station.localPosition); const localPosition = toThreeVector(station.localPosition);
const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition); const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
@@ -294,10 +312,10 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
orbitInclination: orbital.inclination, orbitInclination: orbital.inclination,
localPosition, localPosition,
}); });
context.stationGroup.add(mesh, icon); context.stationGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, station.systemId); context.registerPresentation(mesh, icon, true, true, station.systemId);
context.selectableTargets.set(mesh, { kind: "station", id: station.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id });
context.selectableTargets.set(icon, { kind: "station", id: station.id }); registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id });
} }
} }
@@ -309,8 +327,8 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId); const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId);
const mesh = createClaimMesh(claim); const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18); const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
mesh.position.copy(localPosition); mesh.setPosition(localPosition);
icon.position.copy(localPosition); icon.setPosition(localPosition);
context.claimVisuals.set(claim.id, { context.claimVisuals.set(claim.id, {
id: claim.id, id: claim.id,
nodeId: claim.nodeId, nodeId: claim.nodeId,
@@ -319,10 +337,10 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
icon, icon,
localPosition, localPosition,
}); });
context.claimGroup.add(mesh, icon); context.claimGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, claim.systemId); context.registerPresentation(mesh, icon, true, true, claim.systemId);
context.selectableTargets.set(mesh, { kind: "claim", id: claim.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id });
context.selectableTargets.set(icon, { kind: "claim", id: claim.id }); registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id });
} }
} }
@@ -334,8 +352,8 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
const localPosition = context.resolvePointPosition(site.systemId, site.nodeId); const localPosition = context.resolvePointPosition(site.systemId, site.nodeId);
const mesh = createConstructionSiteMesh(site); const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18); const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
mesh.position.copy(localPosition); mesh.setPosition(localPosition);
icon.position.copy(localPosition); icon.setPosition(localPosition);
context.constructionSiteVisuals.set(site.id, { context.constructionSiteVisuals.set(site.id, {
id: site.id, id: site.id,
nodeId: site.nodeId, nodeId: site.nodeId,
@@ -344,10 +362,10 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
icon, icon,
localPosition, localPosition,
}); });
context.constructionSiteGroup.add(mesh, icon); context.constructionSiteGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, site.systemId); context.registerPresentation(mesh, icon, true, true, site.systemId);
context.selectableTargets.set(mesh, { kind: "construction-site", id: site.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id });
context.selectableTargets.set(icon, { kind: "construction-site", id: site.id }); registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id });
} }
} }
@@ -360,11 +378,11 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
const shipColor = context.shipPresentationColor(ship); const shipColor = context.shipPresentationColor(ship);
const icon = createTacticalIcon(context.documentRef, shipColor, 18); const icon = createTacticalIcon(context.documentRef, shipColor, 18);
const position = toThreeVector(ship.localPosition); const position = toThreeVector(ship.localPosition);
icon.position.copy(position); icon.setPosition(position);
icon.material.color.set(shipColor); icon.setColor(shipColor);
context.shipGroup.add(mesh, icon); context.shipGroup.add(rawObject(mesh), rawObject(icon));
context.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id });
context.selectableTargets.set(icon, { kind: "ship", id: ship.id }); registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id });
context.registerPresentation(mesh, icon, true, true, ship.systemId); context.registerPresentation(mesh, icon, true, true, ship.systemId);
context.shipVisuals.set(ship.id, { context.shipVisuals.set(ship.id, {
systemId: ship.systemId, systemId: ship.systemId,
@@ -390,9 +408,9 @@ export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: Spatial
visual.systemId = node.systemId; visual.systemId = node.systemId;
visual.kind = node.kind; visual.kind = node.kind;
visual.localPosition.copy(toThreeVector(node.localPosition)); visual.localPosition.copy(toThreeVector(node.localPosition));
visual.mesh.position.copy(visual.localPosition); visual.mesh.setPosition(visual.localPosition);
visual.icon.position.copy(visual.localPosition); visual.icon.setPosition(visual.localPosition);
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(context.spatialNodeColor(node.kind)); visual.mesh.setColor(context.spatialNodeColor(node.kind));
} }
} }
@@ -406,8 +424,8 @@ export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: Local
visual.systemId = bubble.systemId; visual.systemId = bubble.systemId;
visual.radius = bubble.radius; visual.radius = bubble.radius;
visual.localPosition.copy(context.resolveBubblePosition(bubble)); visual.localPosition.copy(context.resolveBubblePosition(bubble));
visual.mesh.position.copy(visual.localPosition); visual.mesh.setPosition(visual.localPosition);
visual.mesh.scale.setScalar(Math.max(bubble.radius, 60)); visual.mesh.setScaleScalar(Math.max(bubble.radius, 60));
context.setBubbleVisualState(visual, bubble); context.setBubbleVisualState(visual, bubble);
} }
} }
@@ -427,7 +445,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
visual.orbitRadius = orbital.radius; visual.orbitRadius = orbital.radius;
visual.orbitPhase = orbital.phase; visual.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination; visual.orbitInclination = orbital.inclination;
visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
} }
} }
@@ -445,9 +463,8 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
visual.orbitRadius = orbital.radius; visual.orbitRadius = orbital.radius;
visual.orbitPhase = orbital.phase; visual.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination; visual.orbitInclination = orbital.inclination;
const material = visual.mesh.material as THREE.MeshStandardMaterial; visual.mesh.setColor(station.color);
material.color.set(station.color); visual.mesh.setEmissive(station.color, 0.1);
material.emissive = new THREE.Color(station.color).multiplyScalar(0.1);
} }
} }
@@ -460,11 +477,10 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
visual.systemId = claim.systemId; visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId)); visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId));
visual.mesh.position.copy(visual.localPosition); visual.mesh.setPosition(visual.localPosition);
visual.icon.position.copy(visual.localPosition); visual.icon.setPosition(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial; visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
material.color.set(claim.state === "active" ? "#ff7f50" : "#ff5b5b"); visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020");
material.emissive.set(claim.state === "active" ? "#ffb27d" : "#7a2020");
} }
} }
@@ -477,11 +493,10 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
visual.systemId = site.systemId; visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId)); visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId));
visual.mesh.position.copy(visual.localPosition); visual.mesh.setPosition(visual.localPosition);
visual.icon.position.copy(visual.localPosition); visual.icon.setPosition(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial; visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c");
material.color.set(site.state === "completed" ? "#46d37f" : "#9df29c"); visual.mesh.setScaleScalar(0.75 + site.progress * 0.35);
visual.mesh.scale.setScalar(0.75 + site.progress * 0.35);
} }
} }
@@ -493,16 +508,15 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
} }
visual.systemId = ship.systemId; visual.systemId = ship.systemId;
visual.startPosition.copy(visual.authoritativePosition); visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
visual.authoritativePosition.copy(toThreeVector(ship.localPosition)); visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition)); visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition));
visual.velocity.copy(toThreeVector(ship.localVelocity)); visual.velocity.copy(toThreeVector(ship.localVelocity));
visual.receivedAtMs = performance.now(); visual.receivedAtMs = performance.now();
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
const shipColor = context.shipPresentationColor(ship); const shipColor = context.shipPresentationColor(ship);
const material = visual.mesh.material as THREE.MeshStandardMaterial; visual.mesh.setColor(shipColor);
material.color.set(shipColor); visual.mesh.setEmissive(shipColor, 0.18);
material.emissive.set(new THREE.Color(shipColor).multiplyScalar(0.18)); visual.icon.setColor(shipColor);
visual.icon.material.color.set(shipColor);
} }
} }

View File

@@ -1,4 +1,4 @@
import type { SystemSnapshot } from "./contracts"; import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts";
import type { import type {
CameraMode, CameraMode,
OrbitalAnchor, OrbitalAnchor,
@@ -214,3 +214,205 @@ export function renderSystemDetails(
${followText} ${followText}
`; `;
} }
export function describeShipState(world: WorldState | undefined, ship: ShipSnapshot): string {
const baseState = ship.state;
if (baseState === "capacitor-starved") {
return `${baseState} while ${describeControllerTask(ship.controllerTaskKind)}`;
}
if (!world || (baseState !== "ftl" && baseState !== "spooling-ftl" && baseState !== "warping" && baseState !== "spooling-warp")) {
return baseState;
}
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
if (!destinationNodeId) {
return baseState;
}
const destinationNode = world.spatialNodes.get(destinationNodeId);
if (!destinationNode) {
return `${baseState} -> ${destinationNodeId}`;
}
if (baseState === "warping" || baseState === "spooling-warp") {
const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId);
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
}
const destinationSystem = world.systems.get(destinationNode.systemId);
return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`;
}
function describeControllerTask(taskKind: string): string {
switch (taskKind) {
case "travel":
return "travel";
case "extract":
return "mining";
case "dock":
return "docking";
case "unload":
return "transfer";
case "refuel":
return "refuel";
case "deliver-construction":
return "material delivery";
case "build-construction-site":
return "site construction";
case "construct-module":
return "module construction";
case "undock":
return "undocking";
case "load-workers":
return "worker loading";
case "unload-workers":
return "worker unloading";
default:
return taskKind;
}
}
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
if (!ship.currentAction) {
return undefined;
}
return {
label: ship.currentAction.label,
progress: Math.max(0, Math.min(ship.currentAction.progress, 1)),
};
}
export function describeShipLocation(world: WorldState | undefined, ship: ShipSnapshot): { system: string; local?: string } {
const systemId = ship.spatialState.currentSystemId || ship.systemId;
const system = world?.systems.get(systemId);
const systemLabel = system?.label ?? systemId;
if (!world || !system) {
return { system: systemLabel };
}
if (ship.dockedStationId) {
const station = world.stations.get(ship.dockedStationId);
if (station) {
const anchorPath = station.anchorNodeId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId)
: undefined;
return {
system: systemLabel,
local: anchorPath ? `${anchorPath}/${station.label}` : station.label,
};
}
}
const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId;
if (currentNodeId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId);
if (nodePath) {
return { system: systemLabel, local: nodePath };
}
}
const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId;
if (currentBubbleId) {
const bubble = world.localBubbles.get(currentBubbleId);
if (bubble?.nodeId) {
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId);
if (nodePath) {
return { system: systemLabel, local: nodePath };
}
}
}
return { system: systemLabel };
}
export function describeActiveSpace(
world: WorldState | undefined,
zoomLevel: "local" | "system" | "universe",
activeSystemId: string | undefined,
selectedItems: Selectable[],
): string {
if (!world || zoomLevel === "universe") {
return "deep-space";
}
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
if (!activeSystem) {
return "deep-space";
}
if (zoomLevel !== "local") {
return activeSystem.label;
}
const bubbleId = resolveFocusedBubbleId(world, selectedItems);
if (bubbleId) {
const bubble = world.localBubbles.get(bubbleId);
const localPath = bubble?.nodeId
? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId)
: undefined;
return localPath
? `${activeSystem.label} / ${localPath}`
: activeSystem.label;
}
const selected = selectedItems.length === 1 ? selectedItems[0] : undefined;
if (selected?.kind === "planet" && selected.systemId === activeSystem.id) {
const planet = activeSystem.planets[selected.planetIndex];
return planet
? `${activeSystem.label} / ${planet.label}`
: activeSystem.label;
}
return activeSystem.label;
}
export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined {
const node = world.spatialNodes.get(nodeId);
const system = world.systems.get(systemId);
if (!node || !system) {
return undefined;
}
if (node.parentNodeId) {
const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId);
const segment = describeSpatialNodeSegment(world, system, node);
return parentPath ? `${parentPath}/${segment}` : segment;
}
if (node.kind === "star") {
return undefined;
}
return describeSpatialNodeSegment(world, system, node);
}
function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string {
const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/);
if (moonMatch) {
const moonIndex = Number.parseInt(moonMatch[2], 10);
return `Moon ${moonIndex}`;
}
const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/);
if (lagrangeMatch) {
return lagrangeMatch[1].toUpperCase();
}
const planetMatch = node.id.match(/-planet-(\d+)$/);
if (planetMatch) {
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
}
if (node.kind === "station" && node.occupyingStructureId) {
return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId;
}
if (node.kind === "resource-site") {
return node.orbitReferenceId ?? "Resource Site";
}
return node.orbitReferenceId ?? node.kind;
}

View File

@@ -36,6 +36,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
seed: snapshot.seed, seed: snapshot.seed,
sequence: snapshot.sequence, sequence: snapshot.sequence,
tickIntervalMs: snapshot.tickIntervalMs, tickIntervalMs: snapshot.tickIntervalMs,
orbitalTimeSeconds: snapshot.orbitalTimeSeconds,
orbitalSimulation: snapshot.orbitalSimulation,
generatedAtUtc: snapshot.generatedAtUtc, generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])), systems: new Map(snapshot.systems.map((system) => [system.id, system])),
spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])), spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])),
@@ -55,6 +57,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean { export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean {
world.sequence = delta.sequence; world.sequence = delta.sequence;
world.tickIntervalMs = delta.tickIntervalMs; world.tickIntervalMs = delta.tickIntervalMs;
world.orbitalTimeSeconds = delta.orbitalTimeSeconds;
world.orbitalSimulation = delta.orbitalSimulation;
world.generatedAtUtc = delta.generatedAtUtc; world.generatedAtUtc = delta.generatedAtUtc;
world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18); world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);

View File

@@ -36,6 +36,17 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats:
].join("\n"); ].join("\n");
} }
export function summarizeNetworkStats(networkStats: NetworkStats): string {
const now = performance.now();
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
const recentWindowSeconds = networkStats.throughputSamples.length > 1
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
: 1;
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
const direction = networkStats.streamConnected ? "live" : "offline";
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
}
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) { export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
const now = performance.now(); const now = performance.now();
performanceStats.lastFrameMs = frameMs; performanceStats.lastFrameMs = frameMs;
@@ -89,3 +100,14 @@ export function updatePerformancePanel(
].join("\n"); ].join("\n");
performanceStats.lastPanelUpdateAtMs = now; performanceStats.lastPanelUpdateAtMs = now;
} }
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
const samples = performanceStats.frameSamples;
const elapsedWindowSeconds = samples.length > 1
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
: 1;
const fps = samples.length > 1
? (samples.length - 1) / elapsedWindowSeconds
: 0;
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
}

View File

@@ -1,4 +1,5 @@
import * as THREE from "three"; import * as THREE from "three";
import type { SceneNode } from "./viewerScenePrimitives";
import type { import type {
ClaimSnapshot, ClaimSnapshot,
ConstructionSiteSnapshot, ConstructionSiteSnapshot,
@@ -13,6 +14,7 @@ import type {
SpatialNodeSnapshot, SpatialNodeSnapshot,
StationSnapshot, StationSnapshot,
SystemSnapshot, SystemSnapshot,
OrbitalSimulationSnapshot,
} from "./contracts"; } from "./contracts";
export type ZoomLevel = "local" | "system" | "universe"; export type ZoomLevel = "local" | "system" | "universe";
@@ -33,8 +35,8 @@ export type Selectable =
export interface ShipVisual { export interface ShipVisual {
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
startPosition: THREE.Vector3; startPosition: THREE.Vector3;
authoritativePosition: THREE.Vector3; authoritativePosition: THREE.Vector3;
targetPosition: THREE.Vector3; targetPosition: THREE.Vector3;
@@ -46,16 +48,25 @@ export interface ShipVisual {
export interface PlanetVisual { export interface PlanetVisual {
systemId: string; systemId: string;
planet: PlanetSnapshot; planet: PlanetSnapshot;
orbit: THREE.LineLoop; orbit: SceneNode;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
ring?: THREE.Mesh; ring?: SceneNode;
moons: MoonVisual[]; moons: MoonVisual[];
} }
export interface MoonVisual { export interface MoonVisual {
mesh: THREE.Mesh; systemId: string;
orbit: THREE.LineLoop; planetIndex: number;
mesh: SceneNode;
orbit: SceneNode;
}
export interface OrbitLineVisual {
line: SceneNode;
systemId: string;
kind: "planet" | "moon";
planetIndex: number;
} }
export type OrbitalAnchor = export type OrbitalAnchor =
@@ -65,8 +76,8 @@ export type OrbitalAnchor =
export interface NodeVisual { export interface NodeVisual {
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
sourceKind: string; sourceKind: string;
anchor: OrbitalAnchor; anchor: OrbitalAnchor;
localPosition: THREE.Vector3; localPosition: THREE.Vector3;
@@ -78,8 +89,8 @@ export interface NodeVisual {
export interface SpatialNodeVisual { export interface SpatialNodeVisual {
id: string; id: string;
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
kind: string; kind: string;
localPosition: THREE.Vector3; localPosition: THREE.Vector3;
} }
@@ -87,7 +98,7 @@ export interface SpatialNodeVisual {
export interface BubbleVisual { export interface BubbleVisual {
id: string; id: string;
systemId: string; systemId: string;
mesh: THREE.LineLoop; mesh: SceneNode;
localPosition: THREE.Vector3; localPosition: THREE.Vector3;
radius: number; radius: number;
} }
@@ -96,8 +107,8 @@ export interface ClaimVisual {
id: string; id: string;
nodeId: string; nodeId: string;
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
localPosition: THREE.Vector3; localPosition: THREE.Vector3;
} }
@@ -105,16 +116,16 @@ export interface ConstructionSiteVisual {
id: string; id: string;
nodeId: string; nodeId: string;
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
localPosition: THREE.Vector3; localPosition: THREE.Vector3;
} }
export interface StructureVisual { export interface StructureVisual {
id: string; id: string;
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
anchor: OrbitalAnchor; anchor: OrbitalAnchor;
orbitRadius: number; orbitRadius: number;
orbitPhase: number; orbitPhase: number;
@@ -123,12 +134,12 @@ export interface StructureVisual {
} }
export interface SystemVisual { export interface SystemVisual {
root: THREE.Group; root: SceneNode;
starCluster: THREE.Group; starCluster: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
shellReticle: THREE.Sprite; shellReticle: SceneNode;
shellReticleBaseScale: number; shellReticleBaseScale: number;
detailGroup: THREE.Group; detailGroup: SceneNode;
summary: SystemSummaryVisual; summary: SystemSummaryVisual;
galaxyPosition: THREE.Vector3; galaxyPosition: THREE.Vector3;
} }
@@ -138,6 +149,8 @@ export interface WorldState {
seed: number; seed: number;
sequence: number; sequence: number;
tickIntervalMs: number; tickIntervalMs: number;
orbitalTimeSeconds: number;
orbitalSimulation: OrbitalSimulationSnapshot;
generatedAtUtc: string; generatedAtUtc: string;
systems: Map<string, SystemSnapshot>; systems: Map<string, SystemSnapshot>;
spatialNodes: Map<string, SpatialNodeSnapshot>; spatialNodes: Map<string, SpatialNodeSnapshot>;
@@ -183,15 +196,15 @@ export interface PerformanceStats {
} }
export interface PresentationEntry { export interface PresentationEntry {
detail: THREE.Object3D; detail: SceneNode;
icon: THREE.Sprite; icon: SceneNode;
systemId?: string; systemId?: string;
hideDetailInUniverse?: boolean; hideDetailInUniverse?: boolean;
hideIconInUniverse?: boolean; hideIconInUniverse?: boolean;
} }
export interface SystemSummaryVisual { export interface SystemSummaryVisual {
sprite: THREE.Sprite; sprite: SceneNode;
texture: THREE.CanvasTexture; texture: THREE.CanvasTexture;
anchor: THREE.Vector3; anchor: THREE.Vector3;
} }

View File

@@ -181,7 +181,7 @@ export class ViewerWorldLifecycle {
} }
this.context.setWorldTimeSyncMs(performance.now()); this.context.setWorldTimeSyncMs(performance.now());
const factionsChanged = applyDeltaToWorld(world, delta); applyDeltaToWorld(world, delta);
this.context.applySpatialNodeDeltas(delta.spatialNodes); this.context.applySpatialNodeDeltas(delta.spatialNodes);
this.context.applyLocalBubbleDeltas(delta.localBubbles); this.context.applyLocalBubbleDeltas(delta.localBubbles);
this.context.applyNodeDeltas(delta.nodes); this.context.applyNodeDeltas(delta.nodes);
@@ -189,9 +189,7 @@ export class ViewerWorldLifecycle {
this.context.applyClaimDeltas(delta.claims); this.context.applyClaimDeltas(delta.claims);
this.context.applyConstructionSiteDeltas(delta.constructionSites); this.context.applyConstructionSiteDeltas(delta.constructionSites);
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs); this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
if (factionsChanged) { this.rebuildFactions(cloneFactions(world));
this.rebuildFactions(cloneFactions(world));
}
this.context.updateSystemSummaries(); this.context.updateSystemSummaries();
} }
@@ -201,6 +199,8 @@ export class ViewerWorldLifecycle {
this.context.getSelectedItems(), this.context.getSelectedItems(),
this.context.getCameraMode(), this.context.getCameraMode(),
this.context.getCameraTargetShipId(), this.context.getCameraTargetShipId(),
this.context.getZoomLevel(),
this.context.getActiveSystemId(),
); );
} }

View File

@@ -7,12 +7,14 @@ import {
resolveOrbitalAnchorPosition, resolveOrbitalAnchorPosition,
toThreeVector, toThreeVector,
} from "./viewerMath"; } from "./viewerMath";
import { describeActiveSpace } from "./viewerSelection";
import { import {
resolveShipHeading, resolveShipHeading,
updateSystemStarPresentation, updateSystemStarPresentation,
updateSystemSummaryPresentation, updateSystemSummaryPresentation,
getAnimatedShipLocalPosition, getAnimatedShipLocalPosition,
} from "./viewerPresentation"; } from "./viewerPresentation";
import { rawObject } from "./viewerScenePrimitives";
import type { import type {
LocalBubbleDelta, LocalBubbleDelta,
LocalBubbleSnapshot, LocalBubbleSnapshot,
@@ -22,6 +24,7 @@ import type {
import type { import type {
BubbleVisual, BubbleVisual,
ClaimVisual, ClaimVisual,
Selectable,
ConstructionSiteVisual, ConstructionSiteVisual,
NodeVisual, NodeVisual,
OrbitalAnchor, OrbitalAnchor,
@@ -59,15 +62,17 @@ export interface WorldPresentationContext extends WorldOrbitalContext {
systemSummaryVisuals: Map<string, SystemSummaryVisual>; systemSummaryVisuals: Map<string, SystemSummaryVisual>;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3; toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void; updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void; setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void;
} }
export interface GameStatusParams { export interface GameStatusParams {
statusEl: HTMLDivElement; statusEl: HTMLDivElement;
summaryEl?: HTMLSpanElement;
world?: WorldState; world?: WorldState;
activeSystemId?: string; activeSystemId?: string;
cameraMode: CameraMode; cameraMode: CameraMode;
zoomLevel: ZoomLevel; zoomLevel: ZoomLevel;
selectedItems: Selectable[];
mode: string; mode: string;
} }
@@ -77,59 +82,59 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
for (const visual of context.shipVisuals.values()) { for (const visual of context.shipVisuals.values()) {
const worldPosition = getAnimatedShipLocalPosition(visual, now); const worldPosition = getAnimatedShipLocalPosition(visual, now);
visual.mesh.position.copy(context.toDisplayLocalPosition(worldPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(worldPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
const shipVisible = visual.systemId === context.activeSystemId; const shipVisible = visual.systemId === context.activeSystemId;
visual.mesh.visible = shipVisible; visual.mesh.setVisible(shipVisible);
visual.icon.visible = shipVisible && visual.icon.visible; visual.icon.setVisible(shipVisible && rawObject(visual.icon).visible);
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw); const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
if (desiredHeading.lengthSq() > 0.01) { if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); visual.mesh.lookAt(rawObject(visual.mesh).position.clone().add(desiredHeading));
} }
} }
for (const visual of context.nodeVisuals.values()) { for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.visible = visual.systemId === context.activeSystemId; visual.mesh.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.spatialNodeVisuals.values()) { for (const visual of context.spatialNodeVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.visible = visual.systemId === context.activeSystemId; visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.visible = visual.systemId === context.activeSystemId; visual.icon.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.bubbleVisuals.values()) { for (const visual of context.bubbleVisuals.values()) {
const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.visible = visual.systemId === context.activeSystemId; visual.mesh.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.stationVisuals.values()) { for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds); const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.visible = visual.systemId === context.activeSystemId; visual.mesh.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.claimVisuals.values()) { for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.visible = visual.systemId === context.activeSystemId; visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.visible = visual.systemId === context.activeSystemId; visual.icon.setVisible(visual.systemId === context.activeSystemId);
} }
for (const visual of context.constructionSiteVisuals.values()) { for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone(); const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.setPosition(rawObject(visual.mesh).position.clone());
visual.mesh.visible = visual.systemId === context.activeSystemId; visual.mesh.setVisible(visual.systemId === context.activeSystemId);
visual.icon.visible = visual.systemId === context.activeSystemId; visual.icon.setVisible(visual.systemId === context.activeSystemId);
} }
updateSystemStarPresentation( updateSystemStarPresentation(
@@ -218,22 +223,26 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st
} }
export function updateGameStatus(params: GameStatusParams) { export function updateGameStatus(params: GameStatusParams) {
const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params; const { statusEl, summaryEl, world, activeSystemId, cameraMode, zoomLevel, selectedItems, mode } = params;
const sequence = world?.sequence ?? 0; const sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString() ? new Date(world.generatedAtUtc).toLocaleTimeString()
: "n/a"; : "n/a";
const activeSystem = activeSystemId ?? "deep-space"; const displayZoomLevel = activeSystemId ? zoomLevel : "universe";
const cameraModeLabel = cameraMode === "follow" ? "camera-follow" : "tactical"; const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems);
const cameraModeLabel = cameraMode === "follow" ? "follow" : "map";
statusEl.textContent = [ statusEl.textContent = [
`mode: ${mode}`, `mode: ${mode}`,
`camera: ${cameraModeLabel}`, `camera: ${cameraModeLabel}`,
`zoom: ${zoomLevel}`, `zoom: ${displayZoomLevel}`,
`system: ${activeSystem}`, `space: ${activeSpace}`,
`sequence: ${sequence}`, `sequence: ${sequence}`,
`snapshot: ${generatedAt}`, `snapshot: ${generatedAt}`,
].join("\n"); ].join("\n");
if (summaryEl) {
summaryEl.textContent = `${mode} | ${displayZoomLevel} | ${activeSpace}`;
}
} }
export function deriveNodeOrbital( export function deriveNodeOrbital(
@@ -371,7 +380,7 @@ export function computeSpatialNodeLocalPositionById(
export function setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) { export function setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length; const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length;
const material = visual.mesh.material as THREE.LineBasicMaterial; const material = (rawObject(visual.mesh) as THREE.LineLoop).material as THREE.LineBasicMaterial;
material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72); material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72);
material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff"); material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff");
} }

View File

@@ -13,8 +13,5 @@
"warpDrain": 7, "warpDrain": 7,
"shipRechargeRate": 10, "shipRechargeRate": 10,
"stationSolarCharge": 5 "stationSolarCharge": 5
},
"fuel": {
"warpDrain": 4.5
} }
} }

View File

@@ -12,7 +12,7 @@
"bulk-liquid": 600, "bulk-liquid": 600,
"bulk-gas": 600 "bulk-gas": 600
}, },
"modules": ["docking-clamps", "dock-bay-small", "power-core", "bulk-bay", "liquid-tank"] "modules": ["dock-bay-small", "power-core", "bulk-bay", "liquid-tank"]
}, },
{ {
"id": "trade-hub", "id": "trade-hub",
@@ -22,7 +22,7 @@
"radius": 20, "radius": 20,
"dockingCapacity": 4, "dockingCapacity": 4,
"storage": { "container": 1200, "manufactured": 800 }, "storage": { "container": 1200, "manufactured": 800 },
"modules": ["habitat-ring", "docking-clamps", "container-bay"] "modules": ["habitat-ring", "container-bay"]
}, },
{ {
"id": "refinery", "id": "refinery",
@@ -32,7 +32,7 @@
"radius": 24, "radius": 24,
"dockingCapacity": 3, "dockingCapacity": 3,
"storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 }, "storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 },
"modules": ["docking-clamps", "power-core", "bulk-bay", "liquid-tank", "gas-tank", "refinery-stack", "fuel-processor"] "modules": ["power-core", "bulk-bay", "liquid-tank", "gas-tank", "refinery-stack", "fuel-processor"]
}, },
{ {
"id": "farm-ring", "id": "farm-ring",
@@ -52,7 +52,7 @@
"radius": 24, "radius": 24,
"dockingCapacity": 3, "dockingCapacity": 3,
"storage": { "manufactured": 2200, "container": 1600 }, "storage": { "manufactured": 2200, "container": 1600 },
"modules": ["fabricator-array", "fabricator-array", "container-bay", "docking-clamps"] "modules": ["fabricator-array", "fabricator-array", "container-bay"]
}, },
{ {
"id": "shipyard", "id": "shipyard",
@@ -62,7 +62,7 @@
"radius": 28, "radius": 28,
"dockingCapacity": 5, "dockingCapacity": 5,
"storage": { "manufactured": 1800, "container": 1200 }, "storage": { "manufactured": 1800, "container": 1200 },
"modules": ["docking-clamps", "fabricator-array", "habitat-ring"] "modules": ["component-factory", "ship-factory", "container-bay", "dock-bay-small", "power-core"]
}, },
{ {
"id": "defense-grid", "id": "defense-grid",
@@ -82,6 +82,6 @@
"radius": 34, "radius": 34,
"dockingCapacity": 0, "dockingCapacity": 0,
"storage": { "manufactured": 2400, "container": 800 }, "storage": { "manufactured": 2400, "container": 800 },
"modules": ["ftl-core", "fabricator-array", "docking-clamps"] "modules": ["ftl-core", "fabricator-array"]
} }
] ]

View File

@@ -41,6 +41,96 @@
"storage": "manufactured", "storage": "manufactured",
"summary": "High-value integration kits for hull fitting and final assembly." "summary": "High-value integration kits for hull fitting and final assembly."
}, },
{
"id": "command-bridge-module",
"label": "Command Bridge Module",
"storage": "container",
"summary": "Packaged bridge and combat-information-center assembly for final ship integration."
},
{
"id": "reactor-core-module",
"label": "Reactor Core Module",
"storage": "container",
"summary": "Contained ship reactor package ready for installation into a hull."
},
{
"id": "capacitor-bank-module",
"label": "Capacitor Bank Module",
"storage": "container",
"summary": "Buffered capacitor section for propulsion, weapons, and industrial loads."
},
{
"id": "ion-drive-module",
"label": "Ion Drive Module",
"storage": "container",
"summary": "Preassembled sublight engine unit."
},
{
"id": "ftl-core-module",
"label": "FTL Core Module",
"storage": "container",
"summary": "Integrated FTL drive package for inter-system transit."
},
{
"id": "gun-turret-module",
"label": "Gun Turret Module",
"storage": "container",
"summary": "Shipboard turret mount and fire-control package."
},
{
"id": "carrier-bay-module",
"label": "Carrier Bay Module",
"storage": "container",
"summary": "Hangar and launch-recovery assembly for capital ship integration."
},
{
"id": "habitat-ring-module",
"label": "Habitat Ring Module",
"storage": "container",
"summary": "Crew habitat section packaged for large ship installation."
},
{
"id": "bulk-bay-module",
"label": "Bulk Bay Module",
"storage": "container",
"summary": "Industrial cargo hold segment for raw-solid hauling ships."
},
{
"id": "container-bay-module",
"label": "Container Bay Module",
"storage": "container",
"summary": "Freight rack segment for manufactured and palletized cargo."
},
{
"id": "liquid-tank-module",
"label": "Liquid Tank Module",
"storage": "container",
"summary": "Pressurized liquid storage segment for fuel and energy logistics."
},
{
"id": "gas-tank-module",
"label": "Gas Tank Module",
"storage": "container",
"summary": "Pressurized gas storage segment for volatile cargo hauling."
},
{
"id": "mining-turret-module",
"label": "Mining Turret Module",
"storage": "container",
"summary": "Ship-mounted hard-rock extraction head."
},
{
"id": "gas-extractor-module",
"label": "Gas Extractor Module",
"storage": "container",
"summary": "Cryogenic intake and compression package for gas harvesting ships."
},
{
"id": "fabricator-array-module",
"label": "Fabricator Array Module",
"storage": "container",
"summary": "Mobile industrial fabrication block for constructors."
},
{ {
"id": "gas", "id": "gas",
"label": "Volatile Gas", "label": "Volatile Gas",
@@ -53,6 +143,12 @@
"storage": "bulk-liquid", "storage": "bulk-liquid",
"summary": "Processed liquid fuel consumed by ships and station power systems." "summary": "Processed liquid fuel consumed by ships and station power systems."
}, },
{
"id": "energy-cell",
"label": "Energy Cell",
"storage": "bulk-liquid",
"summary": "Charged energy reserves that can be stored, traded, and discharged into station power grids."
},
{ {
"id": "water", "id": "water",
"label": "Water", "label": "Water",

View File

@@ -13,6 +13,13 @@
{ "itemId": "refined-metals", "amount": 30 } { "itemId": "refined-metals", "amount": 30 }
] ]
}, },
{
"moduleId": "container-bay",
"duration": 10,
"inputs": [
{ "itemId": "refined-metals", "amount": 26 }
]
},
{ {
"moduleId": "fuel-processor", "moduleId": "fuel-processor",
"duration": 14, "duration": 14,
@@ -26,5 +33,36 @@
"inputs": [ "inputs": [
{ "itemId": "refined-metals", "amount": 38 } { "itemId": "refined-metals", "amount": 38 }
] ]
},
{
"moduleId": "fabricator-array",
"duration": 16,
"inputs": [
{ "itemId": "refined-metals", "amount": 48 }
]
},
{
"moduleId": "component-factory",
"duration": 18,
"inputs": [
{ "itemId": "refined-metals", "amount": 54 },
{ "itemId": "ship-equipment", "amount": 12 }
]
},
{
"moduleId": "ship-factory",
"duration": 22,
"inputs": [
{ "itemId": "refined-metals", "amount": 60 },
{ "itemId": "hull-sections", "amount": 24 },
{ "itemId": "ship-equipment", "amount": 14 }
]
},
{
"moduleId": "solar-array",
"duration": 12,
"inputs": [
{ "itemId": "refined-metals", "amount": 28 }
]
} }
] ]

View File

@@ -65,12 +65,6 @@
"category": "cargo-container", "category": "cargo-container",
"summary": "Standardized freight racks." "summary": "Standardized freight racks."
}, },
{
"id": "docking-clamps",
"label": "Docking Clamps",
"category": "dock",
"summary": "Docking collar and transfer arms."
},
{ {
"id": "dock-bay-small", "id": "dock-bay-small",
"label": "Small Dock Bay", "label": "Small Dock Bay",
@@ -113,12 +107,30 @@
"category": "production", "category": "production",
"summary": "Assembly lines for manufactured goods." "summary": "Assembly lines for manufactured goods."
}, },
{
"id": "component-factory",
"label": "Component Factory",
"category": "production",
"summary": "Dedicated lines for assembling ship-grade modules and subsystems."
},
{
"id": "ship-factory",
"label": "Ship Factory",
"category": "shipyard",
"summary": "Final hull integration docks for assembling complete spacecraft from manufactured modules."
},
{ {
"id": "power-core", "id": "power-core",
"label": "Power Core", "label": "Power Core",
"category": "energy", "category": "energy",
"summary": "Primary station generator and power distribution." "summary": "Primary station generator and power distribution."
}, },
{
"id": "solar-array",
"label": "Solar Array",
"category": "energy",
"summary": "External collector wings that generate station power and charge exportable energy cells."
},
{ {
"id": "liquid-tank", "id": "liquid-tank",
"label": "Liquid Tank", "label": "Liquid Tank",

View File

@@ -41,6 +41,31 @@
{ "itemId": "gas", "amount": 20 } { "itemId": "gas", "amount": 20 }
] ]
}, },
{
"id": "fuel-processing",
"label": "Fuel Processing",
"facilityCategory": "station",
"duration": 6,
"priority": 96,
"requiredModules": ["fuel-processor", "power-core"],
"inputs": [
{ "itemId": "gas", "amount": 20 }
],
"outputs": [
{ "itemId": "fuel", "amount": 20 }
]
},
{
"id": "energy-cell-charging",
"label": "Energy Cell Charging",
"facilityCategory": "station",
"duration": 12,
"priority": 72,
"requiredModules": ["solar-array", "liquid-tank"],
"outputs": [
{ "itemId": "energy-cell", "amount": 6 }
]
},
{ {
"id": "water-reclamation", "id": "water-reclamation",
"label": "Water Reclamation", "label": "Water Reclamation",
@@ -143,6 +168,400 @@
{ "itemId": "ship-parts", "amount": 20 } { "itemId": "ship-parts", "amount": 20 }
] ]
}, },
{
"id": "command-bridge-module-assembly",
"label": "Command Bridge Module Assembly",
"facilityCategory": "station",
"duration": 9,
"priority": 52,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 20 },
{ "itemId": "ship-equipment", "amount": 10 }
],
"outputs": [
{ "itemId": "command-bridge-module", "amount": 1 }
]
},
{
"id": "reactor-core-module-assembly",
"label": "Reactor Core Module Assembly",
"facilityCategory": "station",
"duration": 10,
"priority": 54,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 30 },
{ "itemId": "ship-equipment", "amount": 8 }
],
"outputs": [
{ "itemId": "reactor-core-module", "amount": 1 }
]
},
{
"id": "capacitor-bank-module-assembly",
"label": "Capacitor Bank Module Assembly",
"facilityCategory": "station",
"duration": 9,
"priority": 52,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 18 },
{ "itemId": "energy-cell", "amount": 6 }
],
"outputs": [
{ "itemId": "capacitor-bank-module", "amount": 1 }
]
},
{
"id": "ion-drive-module-assembly",
"label": "Ion Drive Module Assembly",
"facilityCategory": "station",
"duration": 10,
"priority": 53,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 22 },
{ "itemId": "ship-equipment", "amount": 8 }
],
"outputs": [
{ "itemId": "ion-drive-module", "amount": 1 }
]
},
{
"id": "ftl-core-module-assembly",
"label": "FTL Core Module Assembly",
"facilityCategory": "station",
"duration": 12,
"priority": 56,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 34 },
{ "itemId": "ship-equipment", "amount": 14 },
{ "itemId": "fuel", "amount": 12 }
],
"outputs": [
{ "itemId": "ftl-core-module", "amount": 1 }
]
},
{
"id": "gun-turret-module-assembly",
"label": "Gun Turret Module Assembly",
"facilityCategory": "station",
"duration": 8,
"priority": 58,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "naval-guns", "amount": 8 },
{ "itemId": "refined-metals", "amount": 12 }
],
"outputs": [
{ "itemId": "gun-turret-module", "amount": 1 }
]
},
{
"id": "carrier-bay-module-assembly",
"label": "Carrier Bay Module Assembly",
"facilityCategory": "station",
"duration": 14,
"priority": 40,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "hull-sections", "amount": 18 },
{ "itemId": "refined-metals", "amount": 18 },
{ "itemId": "ship-equipment", "amount": 10 }
],
"outputs": [
{ "itemId": "carrier-bay-module", "amount": 1 }
]
},
{
"id": "habitat-ring-module-assembly",
"label": "Habitat Ring Module Assembly",
"facilityCategory": "station",
"duration": 12,
"priority": 22,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "hull-sections", "amount": 14 },
{ "itemId": "ship-equipment", "amount": 8 },
{ "itemId": "water", "amount": 10 }
],
"outputs": [
{ "itemId": "habitat-ring-module", "amount": 1 }
]
},
{
"id": "bulk-bay-module-assembly",
"label": "Bulk Bay Module Assembly",
"facilityCategory": "station",
"duration": 8,
"priority": 18,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 16 },
{ "itemId": "hull-sections", "amount": 10 }
],
"outputs": [
{ "itemId": "bulk-bay-module", "amount": 1 }
]
},
{
"id": "container-bay-module-assembly",
"label": "Container Bay Module Assembly",
"facilityCategory": "station",
"duration": 8,
"priority": 18,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 12 },
{ "itemId": "ship-equipment", "amount": 4 }
],
"outputs": [
{ "itemId": "container-bay-module", "amount": 1 }
]
},
{
"id": "liquid-tank-module-assembly",
"label": "Liquid Tank Module Assembly",
"facilityCategory": "station",
"duration": 8,
"priority": 18,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 14 },
{ "itemId": "ship-equipment", "amount": 4 }
],
"outputs": [
{ "itemId": "liquid-tank-module", "amount": 1 }
]
},
{
"id": "gas-tank-module-assembly",
"label": "Gas Tank Module Assembly",
"facilityCategory": "station",
"duration": 8,
"priority": 18,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 14 },
{ "itemId": "ship-equipment", "amount": 4 }
],
"outputs": [
{ "itemId": "gas-tank-module", "amount": 1 }
]
},
{
"id": "mining-turret-module-assembly",
"label": "Mining Turret Module Assembly",
"facilityCategory": "station",
"duration": 9,
"priority": 24,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 18 },
{ "itemId": "ship-equipment", "amount": 6 }
],
"outputs": [
{ "itemId": "mining-turret-module", "amount": 1 }
]
},
{
"id": "gas-extractor-module-assembly",
"label": "Gas Extractor Module Assembly",
"facilityCategory": "station",
"duration": 9,
"priority": 24,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 18 },
{ "itemId": "ship-equipment", "amount": 6 },
{ "itemId": "gas", "amount": 8 }
],
"outputs": [
{ "itemId": "gas-extractor-module", "amount": 1 }
]
},
{
"id": "fabricator-array-module-assembly",
"label": "Fabricator Array Module Assembly",
"facilityCategory": "station",
"duration": 11,
"priority": 20,
"requiredModules": ["component-factory", "container-bay"],
"inputs": [
{ "itemId": "refined-metals", "amount": 24 },
{ "itemId": "ship-equipment", "amount": 10 }
],
"outputs": [
{ "itemId": "fabricator-array-module", "amount": 1 }
]
},
{
"id": "frigate-construction",
"label": "Frigate Construction",
"facilityCategory": "station",
"duration": 24,
"priority": 90,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "frigate",
"inputs": [
{ "itemId": "hull-sections", "amount": 26 },
{ "itemId": "fuel", "amount": 40 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "gun-turret-module", "amount": 1 }
],
"outputs": []
},
{
"id": "destroyer-construction",
"label": "Destroyer Construction",
"facilityCategory": "station",
"duration": 34,
"priority": 70,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "destroyer",
"inputs": [
{ "itemId": "hull-sections", "amount": 44 },
{ "itemId": "fuel", "amount": 60 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "gun-turret-module", "amount": 2 }
],
"outputs": []
},
{
"id": "cruiser-construction",
"label": "Cruiser Construction",
"facilityCategory": "station",
"duration": 42,
"priority": 54,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "cruiser",
"inputs": [
{ "itemId": "hull-sections", "amount": 60 },
{ "itemId": "fuel", "amount": 80 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "gun-turret-module", "amount": 2 }
],
"outputs": []
},
{
"id": "carrier-construction",
"label": "Carrier Construction",
"facilityCategory": "station",
"duration": 60,
"priority": 28,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "carrier",
"inputs": [
{ "itemId": "hull-sections", "amount": 120 },
{ "itemId": "fuel", "amount": 140 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "carrier-bay-module", "amount": 2 },
{ "itemId": "gun-turret-module", "amount": 1 },
{ "itemId": "habitat-ring-module", "amount": 1 }
],
"outputs": []
},
{
"id": "hauler-construction",
"label": "Hauler Construction",
"facilityCategory": "station",
"duration": 26,
"priority": 8,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "hauler",
"inputs": [
{ "itemId": "hull-sections", "amount": 34 },
{ "itemId": "fuel", "amount": 40 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "liquid-tank-module", "amount": 1 }
],
"outputs": []
},
{
"id": "constructor-construction",
"label": "Constructor Construction",
"facilityCategory": "station",
"duration": 30,
"priority": 8,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "constructor",
"inputs": [
{ "itemId": "hull-sections", "amount": 42 },
{ "itemId": "fuel", "amount": 44 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "fabricator-array-module", "amount": 1 },
{ "itemId": "container-bay-module", "amount": 1 }
],
"outputs": []
},
{
"id": "miner-construction",
"label": "Miner Construction",
"facilityCategory": "station",
"duration": 28,
"priority": 8,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "miner",
"inputs": [
{ "itemId": "hull-sections", "amount": 34 },
{ "itemId": "fuel", "amount": 42 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "mining-turret-module", "amount": 1 },
{ "itemId": "bulk-bay-module", "amount": 1 }
],
"outputs": []
},
{
"id": "gas-harvester-construction",
"label": "Gas Harvester Construction",
"facilityCategory": "station",
"duration": 28,
"priority": 8,
"requiredModules": ["ship-factory", "dock-bay-small", "container-bay", "power-core"],
"shipOutputId": "gas-miner",
"inputs": [
{ "itemId": "hull-sections", "amount": 34 },
{ "itemId": "fuel", "amount": 42 },
{ "itemId": "command-bridge-module", "amount": 1 },
{ "itemId": "reactor-core-module", "amount": 1 },
{ "itemId": "capacitor-bank-module", "amount": 1 },
{ "itemId": "ion-drive-module", "amount": 1 },
{ "itemId": "ftl-core-module", "amount": 1 },
{ "itemId": "gas-extractor-module", "amount": 1 },
{ "itemId": "gas-tank-module", "amount": 1 }
],
"outputs": []
},
{ {
"id": "trade-hub-assembly", "id": "trade-hub-assembly",
"label": "Trade Hub Assembly", "label": "Trade Hub Assembly",

View File

@@ -42,7 +42,7 @@
"hullColor": "#314562", "hullColor": "#314562",
"size": 10, "size": 10,
"maxHealth": 340, "maxHealth": 340,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret", "gun-turret", "docking-clamps"] "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret", "gun-turret"]
}, },
{ {
"id": "carrier", "id": "carrier",
@@ -70,13 +70,13 @@
"ftlSpeed": 2600, "ftlSpeed": 2600,
"spoolTime": 3.3, "spoolTime": 3.3,
"cargoCapacity": 180, "cargoCapacity": 180,
"cargoKind": "container", "cargoKind": "bulk-liquid",
"cargoItemId": "drone-parts", "cargoItemId": "energy-cell",
"color": "#b0ff8d", "color": "#b0ff8d",
"hullColor": "#365f2a", "hullColor": "#365f2a",
"size": 8, "size": 8,
"maxHealth": 180, "maxHealth": 180,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "container-bay", "docking-clamps"] "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "liquid-tank"]
}, },
{ {
"id": "constructor", "id": "constructor",
@@ -93,7 +93,7 @@
"hullColor": "#2d5d47", "hullColor": "#2d5d47",
"size": 9, "size": 9,
"maxHealth": 220, "maxHealth": 220,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "fabricator-array", "container-bay", "docking-clamps"] "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "fabricator-array", "container-bay"]
}, },
{ {
"id": "miner", "id": "miner",
@@ -110,7 +110,7 @@
"hullColor": "#68552b", "hullColor": "#68552b",
"size": 6, "size": 6,
"maxHealth": 150, "maxHealth": 150,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay", "docking-clamps"] "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay"]
}, },
{ {
"id": "gas-miner", "id": "gas-miner",
@@ -127,6 +127,6 @@
"hullColor": "#2a5668", "hullColor": "#2a5668",
"size": 6, "size": 6,
"maxHealth": 150, "maxHealth": 150,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gas-extractor", "gas-tank", "docking-clamps"] "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gas-extractor", "gas-tank"]
} }
] ]