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 SystemId,
Vector3Dto LocalPosition,
string? AnchorNodeId,
string SourceKind,
float OreRemaining,
float MaxOre,
@@ -39,6 +40,7 @@ public sealed record ResourceNodeDelta(
string Id,
string SystemId,
Vector3Dto LocalPosition,
string? AnchorNodeId,
string SourceKind,
float OreRemaining,
float MaxOre,

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ public sealed record WorldSnapshot(
int Seed,
long Sequence,
int TickIntervalMs,
double OrbitalTimeSeconds,
OrbitalSimulationSnapshot OrbitalSimulation,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<SpatialNodeSnapshot> SpatialNodes,
@@ -21,6 +23,8 @@ public sealed record WorldSnapshot(
public sealed record WorldDelta(
long Sequence,
int TickIntervalMs,
double OrbitalTimeSeconds,
OrbitalSimulationSnapshot OrbitalSimulation,
DateTimeOffset GeneratedAtUtc,
bool RequiresSnapshotRefresh,
IReadOnlyList<SimulationEventRecord> Events,
@@ -51,3 +55,6 @@ public sealed record ObserverScope(
string ScopeKind,
string? SystemId = 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 UndockDistance { get; set; }
public EnergyBalanceDefinition Energy { get; set; } = new();
public FuelBalanceDefinition Fuel { get; set; } = new();
}
public sealed class EnergyBalanceDefinition
@@ -23,11 +22,6 @@ public sealed class EnergyBalanceDefinition
public float StationSolarCharge { get; set; }
}
public sealed class FuelBalanceDefinition
{
public float WarpDrain { get; set; }
}
public sealed class SolarSystemDefinition
{
public required string Id { get; set; }
@@ -57,6 +51,9 @@ public sealed class ResourceNodeDefinition
public string SourceKind { get; set; } = "asteroid-belt";
public float Angle { 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 required string ItemId { get; set; }
public int ShardCount { get; set; }
@@ -99,6 +96,7 @@ public sealed class RecipeDefinition
public List<string> RequiredModules { get; set; } = [];
public List<RecipeInputDefinition> Inputs { get; set; } = [];
public List<RecipeOutputDefinition> Outputs { get; set; } = [];
public string? ShipOutputId { get; set; }
}
public sealed class PlanetDefinition

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ internal sealed class IdleShipBehaviorState : IShipBehaviorState
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
@@ -30,7 +30,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
ship.DefaultBehavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Kind = ControllerTaskKind.Idle,
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
@@ -39,7 +39,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
Kind = ControllerTaskKind.Travel,
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId,
Threshold = 18f,
@@ -82,6 +82,10 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station";
break;
case ("extract", "node-depleted"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
@@ -114,10 +118,7 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship) ? "refuel" : "deliver-to-site";
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "deliver-to-site";
break;
case ("refuel", "refueled"):
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 ShipSpatialStateRuntime SpatialState { get; set; }
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 required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
@@ -25,6 +25,8 @@ public sealed class ShipRuntime
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public float Health { get; set; }
public string? TrackedActionKey { get; set; }
public float TrackedActionTotal { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
@@ -53,7 +55,7 @@ public sealed class DefaultBehaviorRuntime
public sealed class ControllerTaskRuntime
{
public required string Kind { get; set; }
public required ControllerTaskKind Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; }

View File

@@ -24,6 +24,53 @@ public enum OrderStatus
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 const string UniverseSpace = "universe-space";
@@ -145,4 +192,53 @@ public static class SimulationEnumMappings
OrderStatus.Completed => "completed",
_ => 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, RecipeDefinition> Recipes { get; init; }
public int TickIntervalMs { get; init; } = 200;
public double OrbitalTimeSeconds { 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 SystemId { get; init; }
public required Vector3 Position { get; init; }
public required Vector3 Position { get; set; }
public required string SourceKind { 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 MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty;

View File

@@ -14,17 +14,18 @@ public sealed class StationRuntime
public string? AnchorNodeId { get; set; }
public string? CommanderId { 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> ProductionLaneTimers { get; } = new(StringComparer.Ordinal);
public Dictionary<int, string> DockingPadAssignments { get; } = new();
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float EnergyStored { get; set; }
public float ProcessTimer { get; set; }
public float Population { get; set; }
public float PopulationCapacity { get; set; }
public float WorkforceRequired { get; set; }
public float WorkforceEffectiveRatio { get; set; } = 0.1f;
public float PopulationGrowthProgress { get; set; }
public float ShipProductionProgressSeconds { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
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
{
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
.Select(CloneSystemDefinition)
.ToList();
if (systems.All((system) => system.Id != "sol"))
if (includeSolSystem && systems.All((system) => system.Id != "sol"))
{
systems.Add(CreateSolSystem());
}
@@ -18,13 +23,25 @@ public sealed partial class ScenarioLoader
return systems;
}
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
private static List<SolarSystemDefinition> ExpandSystems(
IReadOnlyList<SolarSystemDefinition> authoredSystems,
int targetSystemCount)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.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;
}
@@ -32,9 +49,11 @@ public sealed partial class ScenarioLoader
var existingIds = systems
.Select((system) => system.Id)
.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 name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
@@ -50,6 +69,63 @@ public sealed partial class ScenarioLoader
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(
SolarSystemDefinition template,
string label,
@@ -66,6 +142,9 @@ public sealed partial class ScenarioLoader
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
@@ -118,8 +197,12 @@ public sealed partial class ScenarioLoader
ResourceNodes = definition.ResourceNodes
.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
@@ -161,6 +244,9 @@ public sealed partial class ScenarioLoader
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
InclinationDegrees = node.InclinationDegrees,
AnchorPlanetIndex = node.AnchorPlanetIndex,
AnchorMoonIndex = node.AnchorMoonIndex,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
@@ -239,9 +325,8 @@ public sealed partial class ScenarioLoader
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 2800f + ((generatedIndex % 5) * 320f);
var oreAmount = 1000f;
for (var index = 0; index < nodeCount; index += 1)
{
@@ -249,7 +334,9 @@ public sealed partial class ScenarioLoader
{
SourceKind = "asteroid-belt",
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,
ItemId = "ore",
ShardCount = 6 + (index % 4),
@@ -269,15 +356,27 @@ public sealed partial class ScenarioLoader
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 gasAmount = 2200f + ((generatedIndex % 4) * 260f);
var gasAmount = 1000f;
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "gas-cloud",
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,
ItemId = "gas",
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
.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)
if (planets.Count == 0)
{
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(
@@ -424,6 +533,15 @@ public sealed partial class ScenarioLoader
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
{
Id = "sol",
@@ -438,30 +556,30 @@ public sealed partial class ScenarioLoader
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = 240,
RadiusOffset = 780f,
RadiusOffset = ScaleSolOrbitRadiusFromAu(2.82f),
RadiusVariance = 180f,
HeightVariance = 22f,
},
ResourceNodes =
[
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, 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 = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, 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 = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 },
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 = 148f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, 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 = 164f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
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 = 228f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, ItemId = "gas", ShardCount = 10 },
],
Planets =
[
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 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("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 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("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 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("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 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("Mercury", "barren", "sphere", 0, mercuryOrbitAu, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
CreateSolPlanet("Venus", "desert", "sphere", 0, venusOrbitAu, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, earthOrbitAu, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
CreateSolPlanet("Mars", "desert", "sphere", 2, marsOrbitAu, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, jupiterOrbitAu, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, 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, uranusOrbitAu, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, 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 shape,
int moonCount,
float orbitRadius,
float orbitSpeed,
float orbitRadiusAu,
float orbitEccentricity,
float orbitInclination,
float ascendingNode,
@@ -488,8 +605,8 @@ public sealed partial class ScenarioLoader
PlanetType = planetType,
Shape = shape,
MoonCount = moonCount,
OrbitRadius = orbitRadius,
OrbitSpeed = orbitSpeed,
OrbitRadius = ScaleSolOrbitRadiusFromAu(orbitRadiusAu),
OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
@@ -506,4 +623,13 @@ public sealed partial class ScenarioLoader
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);
}
return factionIds.Select(CreateFaction).ToList();
factionIds.Add(UnclaimedFactionId);
return factionIds
.Distinct(StringComparer.Ordinal)
.Select(CreateFaction)
.ToList();
}
private static FactionRuntime CreateFaction(string factionId)
@@ -35,6 +40,13 @@ public sealed partial class ScenarioLoader
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
UnclaimedFactionId => new FactionRuntime
{
Id = factionId,
Label = "Unclaimed",
Color = "#7f8794",
Credits = 0f,
},
_ => new FactionRuntime
{
Id = factionId,
@@ -89,30 +101,32 @@ public sealed partial class ScenarioLoader
IReadOnlyCollection<NodeRuntime> nodes,
DateTimeOffset nowUtc)
{
var claims = new List<ClaimRuntime>(stations.Count);
foreach (var station in stations)
var stationsByAnchorNodeId = 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)
{
continue;
}
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
if (anchorNode is null)
{
continue;
}
var owningFactionId = stationsByAnchorNodeId.TryGetValue(node.Id, out var station)
? station.FactionId
: UnclaimedFactionId;
var activatesAtUtc = owningFactionId == UnclaimedFactionId
? nowUtc
: nowUtc.AddSeconds(8);
var state = owningFactionId == UnclaimedFactionId
? ClaimStateKinds.Active
: ClaimStateKinds.Activating;
claims.Add(new ClaimRuntime
{
Id = $"claim-{station.Id}",
FactionId = station.FactionId,
SystemId = station.SystemId,
NodeId = anchorNode.Id,
BubbleId = anchorNode.BubbleId,
Id = $"claim-{node.Id}",
FactionId = owningFactionId,
SystemId = node.SystemId,
NodeId = node.Id,
BubbleId = node.BubbleId,
PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating,
ActivatesAtUtc = activatesAtUtc,
State = state,
Health = 100f,
});
}
@@ -138,8 +152,13 @@ public sealed partial class ScenarioLoader
}
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
var claim = claims.FirstOrDefault((candidate) => candidate.Id == $"claim-{station.Id}");
if (anchorNode is null || claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
if (anchorNode is null)
{
continue;
}
var claim = claims.FirstOrDefault((candidate) => candidate.NodeId == anchorNode.Id);
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
{
continue;
}
@@ -192,9 +211,20 @@ public sealed partial class ScenarioLoader
StationRuntime station,
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))
{
return moduleId;
@@ -220,6 +250,11 @@ public sealed partial class ScenarioLoader
var policies = new List<PolicySetRuntime>(factions.Count);
foreach (var faction in factions)
{
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
{
continue;
}
var policyId = $"policy-{faction.Id}";
faction.DefaultPolicySetId = policyId;
policies.Add(new PolicySetRuntime
@@ -244,6 +279,11 @@ public sealed partial class ScenarioLoader
foreach (var faction in factions)
{
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-faction-{faction.Id}",
@@ -251,9 +291,16 @@ public sealed partial class ScenarioLoader
FactionId = faction.Id,
ControlledEntityId = faction.Id,
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);
factionCommanders[faction.Id] = commander;
faction.CommanderIds.Add(commander.Id);
@@ -334,7 +381,7 @@ public sealed partial class ScenarioLoader
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
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
{
@@ -345,24 +392,23 @@ public sealed partial class ScenarioLoader
}
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
{
Kind = "auto-harvest-gas",
Kind = "auto-supply-energy",
StationId = refinery.Id,
Phase = "travel-to-node",
Phase = "travel-to-source",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
StationId = refinery.Id,
Phase = "travel-to-node",
};
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
}
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()
{
Kind = behavior.Kind,
@@ -402,7 +456,7 @@ public sealed partial class ScenarioLoader
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
{
Kind = task.Kind,
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = targetNodeId ?? task.TargetNodeId,

View File

@@ -35,7 +35,7 @@ public sealed partial class ScenarioLoader
parentNodeId: starNode.Id);
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(
nodes,
@@ -111,22 +111,41 @@ public sealed partial class ScenarioLoader
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 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;
yield return new LagrangePointPlacement("L1", 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(
"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(
"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(
@@ -172,6 +191,39 @@ public sealed partial class ScenarioLoader
_ => "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)
{
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);

View File

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

View File

@@ -7,18 +7,19 @@ public sealed partial class SimulationEngine
var task = ship.ControllerTask;
return task.Kind switch
{
"idle" => UpdateIdle(ship, world, deltaSeconds),
"travel" => UpdateTravel(ship, world, deltaSeconds),
"extract" => UpdateExtract(ship, world, deltaSeconds),
"dock" => UpdateDock(ship, world, deltaSeconds),
"unload" => UpdateUnload(ship, world, deltaSeconds),
"refuel" => UpdateRefuel(ship, world, deltaSeconds),
"deliver-construction" => UpdateDeliverConstruction(ship, world, deltaSeconds),
"build-construction-site" => UpdateBuildConstructionSite(ship, world, deltaSeconds),
"load-workers" => UpdateLoadWorkers(ship, world, deltaSeconds),
"unload-workers" => UpdateUnloadWorkers(ship, world, deltaSeconds),
"construct-module" => UpdateConstructModule(ship, world, deltaSeconds),
"undock" => UpdateUndock(ship, world, deltaSeconds),
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
ControllerTaskKind.Refuel => UpdateRefuel(ship, world, deltaSeconds),
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
ControllerTaskKind.LoadWorkers => UpdateLoadWorkers(ship, world, deltaSeconds),
ControllerTaskKind.UnloadWorkers => UpdateUnloadWorkers(ship, world, deltaSeconds),
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
ControllerTaskKind.Undock => UpdateUndock(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)
{
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -36,7 +37,7 @@ public sealed partial class SimulationEngine
var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -47,7 +48,9 @@ public sealed partial class SimulationEngine
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);
@@ -95,6 +98,11 @@ public sealed partial class SimulationEngine
.FirstOrDefault();
}
private static NodeRuntime? ResolveSystemEntryNode(SimulationWorld world, string systemId) =>
world.SpatialNodes.FirstOrDefault(candidate =>
candidate.SystemId == systemId &&
candidate.Kind == SpatialNodeKind.Star);
private string UpdateLocalTravel(
ShipRuntime ship,
SimulationWorld world,
@@ -119,19 +127,19 @@ public sealed partial class SimulationEngine
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.State = "arriving";
ship.State = ShipState.Arriving;
return "arrived";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.ActionTimer = 0f;
ship.State = "local-flight";
ship.State = ShipState.LocalFlight;
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
@@ -158,32 +166,32 @@ public sealed partial class SimulationEngine
ship.SpatialState.DestinationNodeId = targetNode.Id;
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;
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "spooling-warp";
ship.State = ShipState.SpoolingWarp;
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{
return "none";
}
ship.State = "warping";
ship.State = ShipState.Warping;
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -220,32 +228,32 @@ public sealed partial class SimulationEngine
ship.SpatialState.CurrentBubbleId = null;
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;
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "spooling-ftl";
ship.State = ShipState.SpoolingFtl;
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{
return "none";
}
ship.State = "ftl";
ship.State = ShipState.Ftl;
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -254,7 +262,7 @@ public sealed partial class SimulationEngine
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 24f
? CompleteTransitArrival(ship, targetSystemId, targetPosition, targetNode)
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetNode)
: "none";
}
@@ -270,7 +278,23 @@ public sealed partial class SimulationEngine
ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.SpatialState.DestinationNodeId = targetNode?.Id;
ship.State = "arriving";
ship.State = ShipState.Arriving;
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);
}
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 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;
yield return new LagrangePointPlacement("L1", 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(
"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(
"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)
@@ -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);
foreach (var system in world.Systems)
@@ -150,7 +185,7 @@ public sealed partial class SimulationEngine
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
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()}";
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))
{
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
{
private const float StationEnergyCellToEnergyRatio = 1f;
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
@@ -19,7 +21,7 @@ public sealed partial class SimulationEngine
foreach (var station in world.Stations)
{
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)
{
@@ -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 tanks = CountModules(station.InstalledModules, "liquid-tank");
@@ -53,6 +55,32 @@ public sealed partial class SimulationEngine
var energyCapacity = powerCores * StationEnergyPerPowerCore;
var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
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)
{
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
@@ -69,6 +97,37 @@ public sealed partial class SimulationEngine
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)
{
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
@@ -166,11 +225,265 @@ public sealed partial class SimulationEngine
_ => false,
};
private static bool CanBuildClaimBeacon(ShipRuntime ship) =>
string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal);
private static float GetShipFuelCapacity(ShipRuntime ship) =>
CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor;
internal static bool NeedsRefuel(ShipRuntime ship) =>
GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f);
private static float GetShipAvailableEnergyBudget(ShipRuntime ship) =>
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)
{
@@ -206,7 +519,8 @@ public sealed partial class SimulationEngine
return 0f;
}
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity))
var capacity = GetStationStorageCapacity(station, storageClass);
if (capacity <= 0.01f)
{
return 0f;
}
@@ -232,6 +546,17 @@ public sealed partial class SimulationEngine
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
private static bool IsConstructionSiteReady(ConstructionSiteRuntime site) =>
site.RequiredItems.All(entry => GetInventoryAmount(site.DeliveredItems, entry.Key) + 0.001f >= entry.Value);
private static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
{
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,
sequence,
world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc,
world.Systems.Select(system => new SystemSnapshot(
system.Definition.Id,
@@ -59,11 +61,12 @@ public sealed partial class SimulationEngine
node.Id,
node.SystemId,
node.LocalPosition,
node.AnchorNodeId,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
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.Label,
station.Category,
@@ -74,8 +77,13 @@ public sealed partial class SimulationEngine
station.AnchorNodeId,
station.Color,
station.DockedShips,
station.DockedShipIds,
station.DockingPads,
station.FuelStored,
station.FuelCapacity,
station.EnergyStored,
station.EnergyCapacity,
station.CurrentProcesses,
station.Inventory,
station.FactionId,
station.CommanderId,
@@ -135,7 +143,7 @@ public sealed partial class SimulationEngine
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
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.Label,
ship.Role,
@@ -154,12 +162,14 @@ public sealed partial class SimulationEngine
ship.CommanderId,
ship.PolicySetId,
ship.CargoCapacity,
ship.CargoItemId,
ship.WorkerPopulation,
ship.EnergyStored,
ship.Inventory,
ship.FactionId,
ship.Health,
ship.History,
ship.CurrentAction,
ship.SpatialState)).ToList(),
world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot(
faction.Id,
@@ -193,7 +203,7 @@ public sealed partial class SimulationEngine
foreach (var station in world.Stations)
{
station.LastDeltaSignature = BuildStationSignature(station);
station.LastDeltaSignature = BuildStationSignature(world, station);
}
foreach (var claim in world.Claims)
@@ -218,7 +228,7 @@ public sealed partial class SimulationEngine
foreach (var ship in world.Ships)
{
ship.LastDeltaSignature = BuildShipSignature(ship);
ship.LastDeltaSignature = BuildShipSignature(world, ship);
}
foreach (var faction in world.Factions)
@@ -286,14 +296,14 @@ public sealed partial class SimulationEngine
var deltas = new List<StationDelta>();
foreach (var station in world.Stations)
{
var signature = BuildStationSignature(station);
var signature = BuildStationSignature(world, station);
if (signature == station.LastDeltaSignature)
{
continue;
}
station.LastDeltaSignature = signature;
deltas.Add(ToStationDelta(station));
deltas.Add(ToStationDelta(world, station));
}
return deltas;
@@ -371,19 +381,19 @@ public sealed partial class SimulationEngine
return deltas;
}
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
private IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
{
var deltas = new List<ShipDelta>();
foreach (var ship in world.Ships)
{
var signature = BuildShipSignature(ship);
var signature = BuildShipSignature(world, ship);
if (signature == ship.LastDeltaSignature)
{
continue;
}
ship.LastDeltaSignature = signature;
deltas.Add(ToShipDelta(ship));
deltas.Add(ToShipDelta(world, ship));
}
return deltas;
@@ -407,7 +417,8 @@ public sealed partial class SimulationEngine
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) =>
$"{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) =>
$"{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) =>
$"{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"}";
private static string BuildStationSignature(SimulationWorld world, StationRuntime station)
{
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) =>
$"{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) =>
$"{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("|",
ship.SystemId,
ship.Position.X.ToString("0.###"),
@@ -442,10 +479,10 @@ public sealed partial class SimulationEngine
ship.TargetPosition.X.ToString("0.###"),
ship.TargetPosition.Y.ToString("0.###"),
ship.TargetPosition.Z.ToString("0.###"),
ship.State,
ship.State.ToContractValue(),
ship.Order?.Kind ?? "none",
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.ControllerTask.Kind.ToContractValue(),
ship.SpatialState.CurrentNodeId ?? "none",
ship.SpatialState.CurrentBubbleId ?? "none",
ship.DockedStationId ?? "none",
@@ -463,8 +500,14 @@ public sealed partial class SimulationEngine
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).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.Health.ToString("0.###"));
ship.Health.ToString("0.###"),
ship.ActionTimer.ToString("0.###"));
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",",
@@ -480,6 +523,7 @@ public sealed partial class SimulationEngine
node.Id,
node.SystemId,
ToDto(node.Position),
node.AnchorNodeId,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
@@ -505,7 +549,7 @@ public sealed partial class SimulationEngine
bubble.OccupantClaimIds.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.Definition.Label,
station.Definition.Category,
@@ -516,8 +560,13 @@ public sealed partial class SimulationEngine
station.AnchorNodeId,
station.Definition.Color,
station.DockedShipIds.Count,
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
GetDockingPadCount(station),
GetInventoryAmount(station.Inventory, "fuel"),
GetStationFuelCapacity(station),
station.EnergyStored,
GetStationEnergyCapacity(station),
ToStationActionProgressSnapshots(world, station),
ToInventoryEntries(station.Inventory),
station.FactionId,
station.CommanderId,
@@ -529,6 +578,23 @@ public sealed partial class SimulationEngine
station.InstalledModules.OrderBy(moduleId => moduleId, 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(
claim.Id,
claim.FactionId,
@@ -582,7 +648,7 @@ public sealed partial class SimulationEngine
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy);
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) => new(
ship.Id,
ship.Definition.Label,
ship.Definition.Role,
@@ -591,24 +657,75 @@ public sealed partial class SimulationEngine
ToDto(ship.Position),
ToDto(ship.Velocity),
ToDto(ship.TargetPosition),
ship.State,
ship.State.ToContractValue(),
ship.Order?.Kind,
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.ControllerTask.Kind.ToContractValue(),
ship.SpatialState.CurrentNodeId,
ship.SpatialState.CurrentBubbleId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.Definition.CargoCapacity,
ship.Definition.CargoItemId,
ship.WorkerPopulation,
ship.EnergyStored,
ToInventoryEntries(ship.Inventory),
ship.FactionId,
ship.Health,
ship.History.ToList(),
ToShipActionProgressSnapshot(world, ship),
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) =>
inventory
.Where(entry => entry.Value > 0.001f)
@@ -647,9 +764,9 @@ public sealed partial class SimulationEngine
private static void EmitShipStateEvents(
ShipRuntime ship,
string previousState,
ShipState previousState,
string previousBehavior,
string previousTask,
ControllerTaskKind previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
@@ -657,7 +774,7 @@ public sealed partial class SimulationEngine
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)
@@ -667,7 +784,7 @@ public sealed partial class SimulationEngine
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")

View File

@@ -65,7 +65,7 @@ public sealed partial class SimulationEngine
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.State = remaining <= 0.01f
? MarketOrderStateKinds.Filled
@@ -78,11 +78,6 @@ public sealed partial class SimulationEngine
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)
{
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)
{
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))
{
return moduleId;
@@ -133,7 +154,10 @@ public sealed partial class SimulationEngine
{
order.State = MarketOrderStateKinds.Cancelled;
order.RemainingAmount = 0f;
world.MarketOrders.Remove(order);
}
station.MarketOrderIds.Remove(orderId);
}
site.MarketOrderIds.Clear();
@@ -214,7 +238,7 @@ public sealed partial class SimulationEngine
{
var padCount = Math.Max(1, GetDockingPadCount(station));
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
var radius = station.Definition.Radius + 14f;
var radius = station.Definition.Radius + 18f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
@@ -225,7 +249,7 @@ public sealed partial class SimulationEngine
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Definition.Radius + 34f;
var radius = station.Definition.Radius + 24f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
@@ -259,4 +283,25 @@ public sealed partial class SimulationEngine
ship.AssignedDockingPadIndex is int padIndex
? GetDockingPadPosition(station, padIndex)
: 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;
}
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)
{
var cargoItemId = ship.Definition.CargoItemId;
@@ -26,11 +37,20 @@ public sealed partial class SimulationEngine
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node))
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
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;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
@@ -38,42 +58,47 @@ public sealed partial class SimulationEngine
ship.ActionTimer = 0f;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "mining-approach";
ship.State = ShipState.MiningApproach;
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "mining";
ship.State = ShipState.Mining;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{
return "none";
}
var cargoAmount = GetShipCargoAmount(ship);
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount);
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
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)
{
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
}
node.OreRemaining -= mined;
if (node.OreRemaining <= 0f)
{
node.OreRemaining = node.MaxOre;
}
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
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);
if (station is null || task.TargetPosition is null)
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -93,7 +118,7 @@ public sealed partial class SimulationEngine
if (padIndex is null)
{
ship.ActionTimer = 0f;
ship.State = "awaiting-dock";
ship.State = ShipState.AwaitingDock;
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
@@ -113,37 +138,37 @@ public sealed partial class SimulationEngine
ship.ActionTimer = 0f;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "docking-approach";
ship.State = ShipState.DockingApproach;
ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "docking";
ship.State = ShipState.Docking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{
return "none";
}
ship.State = "docked";
ship.State = ShipState.Docked;
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition;
@@ -155,7 +180,7 @@ public sealed partial class SimulationEngine
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -165,15 +190,14 @@ public sealed partial class SimulationEngine
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !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;
return "none";
}
@@ -181,7 +205,8 @@ public sealed partial class SimulationEngine
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "transferring";
ship.State = ShipState.Transferring;
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
var cargoItemId = ship.Definition.CargoItemId;
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
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";
}
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -215,15 +240,14 @@ public sealed partial class SimulationEngine
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !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;
return "none";
}
@@ -231,8 +255,58 @@ public sealed partial class SimulationEngine
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "refueling";
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel"));
ship.State = ShipState.Loading;
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"));
if (moved > 0.01f)
{
@@ -240,31 +314,40 @@ public sealed partial class SimulationEngine
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)
{
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;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{
ship.AssignedDockingPadIndex = null;
ship.State = "idle";
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.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -272,22 +355,22 @@ public sealed partial class SimulationEngine
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{
ship.ActionTimer = 0f;
ship.State = "waiting-materials";
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.State = ShipState.WaitingMaterials;
ship.TargetPosition = supportPosition;
return "none";
}
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{
ship.State = "construction-blocked";
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.State = ShipState.ConstructionBlocked;
ship.TargetPosition = supportPosition;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "constructing";
ship.State = ShipState.Constructing;
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
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)
{
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;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = "idle";
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 = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
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)
{
@@ -349,49 +447,58 @@ public sealed partial class SimulationEngine
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, 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)
{
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;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
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)
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
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.TargetPosition = GetShipDockedPosition(ship, station);
ship.State = ShipState.LocalFlight;
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";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.TargetPosition = supportPosition;
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "constructing";
ship.State = ShipState.Constructing;
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration)
@@ -404,22 +511,38 @@ public sealed partial class SimulationEngine
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)
{
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{
ship.State = "blocked";
ship.State = ShipState.Blocked;
return "failed";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || station.Population <= 0.01f)
{
ship.State = "idle";
ship.State = ShipState.Idle;
return "none";
}
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);
if (transfer <= 0.01f)
{
@@ -428,7 +551,8 @@ public sealed partial class SimulationEngine
station.Population = MathF.Max(0f, station.Population - 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";
}
@@ -436,18 +560,19 @@ public sealed partial class SimulationEngine
{
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{
ship.State = "blocked";
ship.State = ShipState.Blocked;
return "failed";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || ship.WorkerPopulation <= 0.01f)
{
ship.State = "idle";
ship.State = ShipState.Idle;
return "none";
}
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
var totalTransfer = transfer;
transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f)
{
@@ -456,7 +581,8 @@ public sealed partial class SimulationEngine
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - 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";
}
@@ -465,7 +591,7 @@ public sealed partial class SimulationEngine
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = "idle";
ship.State = ShipState.Idle;
ship.TargetPosition = ship.Position;
return "none";
}
@@ -477,19 +603,19 @@ public sealed partial class SimulationEngine
ship.TargetPosition = undockTarget;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.State = ShipState.CapacitorStarved;
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "undocking";
ship.State = ShipState.Undocking;
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{
if (station is not null)
@@ -516,4 +642,7 @@ public sealed partial class SimulationEngine
ship.AssignedDockingPadIndex = null;
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
{
Kind = commander.ActiveTask.Kind,
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
Status = commander.ActiveTask.Status,
CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId,
@@ -81,8 +81,8 @@ public sealed partial class SimulationEngine
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
}
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind };
commander.ActiveTask.Kind = ship.ControllerTask.Kind;
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
@@ -121,7 +121,7 @@ public sealed partial class SimulationEngine
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
Kind = ControllerTaskKind.Travel,
Status = WorkStatus.Active,
CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId,
@@ -144,10 +144,13 @@ public sealed partial class SimulationEngine
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? 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)
.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))
{
@@ -157,13 +160,29 @@ public sealed partial class SimulationEngine
}
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 (GetShipCargoAmount(ship) > 0.01f)
{
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";
}
@@ -172,7 +191,7 @@ public sealed partial class SimulationEngine
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";
}
@@ -180,19 +199,20 @@ public sealed partial class SimulationEngine
switch (behavior.Phase)
{
case "extract":
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "extract",
Kind = ControllerTaskKind.Extract,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 14f,
TargetPosition = extractionPosition,
Threshold = 5f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
Kind = ControllerTaskKind.Travel,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
@@ -202,7 +222,7 @@ public sealed partial class SimulationEngine
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "dock",
Kind = ControllerTaskKind.Dock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
@@ -212,7 +232,7 @@ public sealed partial class SimulationEngine
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "unload",
Kind = ControllerTaskKind.Unload,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
@@ -222,7 +242,7 @@ public sealed partial class SimulationEngine
case "refuel":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "refuel",
Kind = ControllerTaskKind.Refuel,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
@@ -232,7 +252,7 @@ public sealed partial class SimulationEngine
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "undock",
Kind = ControllerTaskKind.Undock,
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
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:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
Kind = ControllerTaskKind.Travel,
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
@@ -278,6 +298,153 @@ public sealed partial class SimulationEngine
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)
{
var behavior = ship.DefaultBehavior;
@@ -298,17 +465,36 @@ public sealed partial class SimulationEngine
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";
}
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";
}
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";
}
@@ -325,81 +511,71 @@ public sealed partial class SimulationEngine
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";
}
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":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "refuel",
Kind = ControllerTaskKind.Refuel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "construct-module",
Kind = ControllerTaskKind.ConstructModule,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "deliver-construction",
Kind = ControllerTaskKind.DeliverConstruction,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "build-construction-site",
Kind = ControllerTaskKind.BuildConstructionSite,
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Kind = ControllerTaskKind.Idle,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
TargetPosition = constructionHoldPosition,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
Kind = ControllerTaskKind.Travel,
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = station.Definition.Radius + 8f,
TargetPosition = constructionHoldPosition,
Threshold = 10f,
};
behavior.Phase = "travel-to-station";
break;
@@ -412,7 +588,7 @@ public sealed partial class SimulationEngine
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = "idle";
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
if (commander is not 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)
{
return;
}
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)
{
ship.History.RemoveAt(0);
@@ -458,10 +638,27 @@ public sealed partial class SimulationEngine
private static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = "idle",
Kind = ControllerTaskKind.Idle,
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)
{
if (commander is null)
@@ -471,7 +668,7 @@ public sealed partial class SimulationEngine
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = task.Kind,
Kind = task.Kind.ToContractValue(),
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId,

View File

@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private const int StrategicControlTargetSystems = 5;
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
@@ -62,18 +64,27 @@ public sealed partial class SimulationEngine
var desiredOrders = new List<DesiredMarketOrder>();
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 refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 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, "energy-cell", energyCellReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f);
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, "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, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
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)
{
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);
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
.Where(recipe => RecipeAppliesToStation(station, recipe))
.OrderByDescending(recipe => recipe.Priority)
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(recipe), laneKey, StringComparison.Ordinal))
.OrderByDescending(recipe => GetStationRecipePriority(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)
{
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)
{
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))
{
return false;
@@ -165,7 +295,8 @@ public sealed partial class SimulationEngine
return false;
}
if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity))
var capacity = GetStationStorageCapacity(station, itemDefinition.Storage);
if (capacity <= 0.01f)
{
return false;
}
@@ -259,5 +390,151 @@ public sealed partial class SimulationEngine
private static bool CanProcessFuel(StationRuntime station) =>
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);
}

View File

@@ -4,6 +4,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private readonly OrbitalSimulationOptions _orbitalSimulation;
private const float ShipFuelToEnergyRatio = 12f;
private const float StationFuelToEnergyRatio = 18f;
private const float CapacitorEnergyPerModule = 120f;
@@ -16,7 +17,7 @@ public sealed partial class SimulationEngine
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
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) => UpdateConstructionSites(world, 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)),
];
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
{
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
}
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
{
var events = new List<SimulationEventRecord>();
var nowUtc = DateTimeOffset.UtcNow;
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
foreach (var step in _worldUpdatePipeline)
{
@@ -54,7 +61,7 @@ public sealed partial class SimulationEngine
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
AdvanceControlState(ship, world, controllerEvent);
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
TrackHistory(ship);
TrackHistory(ship, controllerEvent);
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
}
@@ -65,6 +72,8 @@ public sealed partial class SimulationEngine
return new WorldDelta(
sequence,
world.TickIntervalMs,
world.OrbitalTimeSeconds,
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
world.GeneratedAtUtc,
false,
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 Microsoft.Extensions.Options;
using SpaceGame.Simulation.Api.Contracts;
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 readonly object _sync = new();
private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
private readonly SimulationEngine _engine = new();
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
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;
public WorldSnapshot GetSnapshot()
@@ -98,6 +103,8 @@ public sealed class WorldService(IWebHostEnvironment environment)
var resetDelta = new WorldDelta(
_sequence,
_world.TickIntervalMs,
_world.OrbitalTimeSeconds,
_orbitalSimulation,
DateTimeOffset.UtcNow,
true,
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],

View File

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

View File

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

View File

@@ -55,6 +55,7 @@ import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import type { SceneNode } from "./viewerScenePrimitives";
import type { FactionSnapshot, ShipSnapshot } from "./contracts";
import type {
BubbleVisual,
@@ -66,6 +67,7 @@ import type {
MoonVisual,
NetworkStats,
NodeVisual,
OrbitLineVisual,
OrbitalAnchor,
PerformanceStats,
PlanetVisual,
@@ -101,6 +103,7 @@ export class ViewerAppController {
private readonly constructionSiteGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group();
private readonly ambienceGroup = new THREE.Group();
private readonly gamePanelEl: HTMLDivElement;
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = [];
private readonly nodeVisuals = new Map<string, NodeVisual>();
@@ -113,15 +116,20 @@ export class ViewerAppController {
private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
private readonly planetVisuals: PlanetVisual[] = [];
private readonly orbitLines: THREE.Object3D[] = [];
private readonly orbitLines: OrbitLineVisual[] = [];
private readonly statusEl: HTMLDivElement;
private readonly gameSummaryEl: HTMLSpanElement;
private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement;
private readonly networkSectionEl: HTMLDivElement;
private readonly networkSummaryEl: HTMLSpanElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly performanceSectionEl: HTMLDivElement;
private readonly performanceSummaryEl: HTMLSpanElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
@@ -179,15 +187,32 @@ export class ViewerAppController {
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800);
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);
this.gamePanelEl = hud.gamePanelEl;
this.statusEl = hud.statusEl;
this.gameSummaryEl = hud.gameSummaryEl;
this.networkSectionEl = hud.networkSectionEl;
this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl;
this.factionStripEl = hud.factionStripEl;
this.networkSummaryEl = hud.networkSummaryEl;
this.networkPanelEl = hud.networkPanelEl;
this.performanceSectionEl = hud.performanceSectionEl;
this.performanceSummaryEl = hud.performanceSummaryEl;
this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl;
@@ -200,13 +225,31 @@ export class ViewerAppController {
worldLifecycle: this.worldLifecycle,
interactionController: this.interactionController,
} = createViewerControllers(this));
this.presentationController.initializeAmbience();
this.container.append(this.renderer.domElement, hud.root);
this.initializePanelToggles();
wireViewerEvents(this);
this.onResize();
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() {
await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
@@ -308,8 +351,8 @@ export class ViewerAppController {
}
private registerPresentation(
detail: THREE.Object3D,
icon: THREE.Sprite,
detail: SceneNode,
icon: SceneNode,
hideDetailInUniverse: boolean,
hideIconInUniverse = false,
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,7 +96,7 @@ canvas {
.topbar {
border-radius: 22px;
padding: 18px 20px;
padding: 14px 16px;
pointer-events: auto;
}
@@ -124,8 +124,48 @@ canvas {
.topbar h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
font-size: 0.64rem;
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 {
@@ -139,7 +179,7 @@ canvas {
.info-panel {
border-radius: 24px;
padding: 18px;
padding: 16px;
color: var(--text);
pointer-events: auto;
overflow: auto;
@@ -147,7 +187,7 @@ canvas {
.network-panel {
border-radius: 24px;
padding: 18px;
padding: 14px 16px;
color: var(--text);
pointer-events: auto;
}
@@ -155,7 +195,7 @@ canvas {
.performance-panel {
width: min(360px, calc(100vw - 40px));
border-radius: 24px;
padding: 18px;
padding: 14px 16px;
color: var(--text);
pointer-events: auto;
}
@@ -172,7 +212,8 @@ canvas {
margin: 0;
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
font-size: 0.64rem;
line-height: 1;
text-transform: uppercase;
}
@@ -186,6 +227,20 @@ canvas {
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 {
margin-top: 12px;
font-size: 1.05rem;
@@ -208,6 +263,40 @@ canvas {
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 {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.78rem;
@@ -329,7 +418,7 @@ canvas {
left: 0;
right: 0;
bottom: 0;
width: 100vw;
width: 50vw;
min-height: 128px;
border-radius: 0;
padding: 0;
@@ -412,12 +501,16 @@ canvas {
font-size: 0.72rem;
}
.ship-card-header + p {
.ship-card-header+p {
font-size: 0.62rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ship-action-progress {
margin-top: 2px;
}
.ship-card-ai {
margin-top: 2px;
padding-top: 6px;
@@ -495,7 +588,7 @@ canvas {
left: 0;
right: 0;
bottom: 0;
width: 100vw;
width: 50vw;
min-height: 120px;
}

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,38 @@
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(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
zoomLevel?: ZoomLevel,
activeSystemId?: string,
) {
if (!world) {
return "";
}
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));
return ships
.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
&& selectedItems[0].kind === "ship"
&& selectedItems[0].id === ship.id;
@@ -37,9 +53,20 @@ export function renderFactionStrip(
>&#128340;</button>
</div>
</div>
<p>${ship.systemId}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
<p>State ${ship.state}</p>
<p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
<p>Fuel ${fuel.toFixed(1)} · Cap ${ship.energyStored.toFixed(1)}${ship.cargoCapacity > 0 ? ` · Cargo ${cargo.toFixed(0)}` : ""}</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">
<p>Order ${ship.orderKind ?? "none"}</p>
<p>Behavior ${ship.defaultBehaviorKind}</p>

View File

@@ -1,13 +1,19 @@
export interface ViewerHudElements {
root: HTMLDivElement;
gamePanelEl: HTMLDivElement;
statusEl: HTMLDivElement;
gameSummaryEl: HTMLSpanElement;
networkSectionEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
factionStripEl: HTMLDivElement;
networkSummaryEl: HTMLSpanElement;
networkPanelEl: HTMLDivElement;
performanceSectionEl: HTMLDivElement;
performanceSummaryEl: HTMLSpanElement;
performancePanelEl: HTMLDivElement;
errorEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
@@ -20,16 +26,34 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
root.className = "viewer-shell";
root.innerHTML = `
<div class="left-panel-stack">
<header class="topbar">
<h2>Game</h2>
<header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
<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>
</header>
<aside class="network-panel">
<h2>Network</h2>
<aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
<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>
</aside>
<aside class="performance-panel">
<h2>Performance</h2>
<aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
<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>
</aside>
</div>
@@ -54,14 +78,20 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
return {
root,
gamePanelEl: root.querySelector(".topbar") 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,
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement,
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
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,
errorEl: root.querySelector(".error-strip") 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);
this.context.setSelectedItems(picked ? [picked] : []);
if (picked && this.shouldFocusSelectionOnClick(picked)) {
this.context.focusOnSelection(picked);
}
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
@@ -294,4 +297,12 @@ export class ViewerInteractionController {
this.context.syncFollowStateFromSelection();
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;
}
const baseUtcMs = Date.parse(world.generatedAtUtc);
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 {

View File

@@ -1,5 +1,5 @@
import { formatInventory, formatVector } from "./viewerMath";
import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import { formatInventory, formatVector, inventoryAmount } from "./viewerMath";
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
HistoryWindowState,
@@ -31,6 +31,29 @@ interface SystemPanelParams {
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(
detailTitleEl: HTMLHeadingElement,
detailBodyEl: HTMLDivElement,
@@ -79,12 +102,30 @@ export function updateDetailPanel(
return;
}
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;
detailBodyEl.innerHTML = `
<p>Parent ${parent}</p>
<p>State ${ship.state}</p>
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>State ${shipState}</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>Velocity ${formatVector(ship.localVelocity)}</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;
}
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;
detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips} / ${station.dockingPads}</p>
<p>Inventory ${formatInventory(station.inventory)}</p>
${stationProcessingHtml}
<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>
`;
return;
@@ -115,11 +187,23 @@ export function updateDetailPanel(
return;
}
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}`;
detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>Parent ${parent}</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>
`;
return;
@@ -240,7 +324,9 @@ export function updateSystemPanel(params: SystemPanelParams) {
}
systemTitleEl.textContent = activeSystem.label;
systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId);
systemBodyEl.innerHTML = `
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
`;
}
export function describeSelectionParent(
@@ -270,8 +356,13 @@ export function describeSelectionParent(
}
if (selection.kind === "station") {
const station = world.stations.get(selection.id);
const visual = station ? stationVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, station?.systemId, visual?.anchor);
if (!station) {
return "unknown";
}
return station.anchorNodeId
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network`
: "unknown";
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);

View File

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

View File

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

View File

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

View File

@@ -24,8 +24,10 @@ import {
starHaloOpacity,
toThreeVector,
} 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 mesh = new THREE.Mesh(
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.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);
return new THREE.Mesh(
return createSceneNode(new THREE.Mesh(
new THREE.OctahedronGeometry(10, 0),
new THREE.MeshStandardMaterial({
color,
@@ -52,14 +54,14 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo
roughness: 0.35,
metalness: 0.45,
}),
);
));
}
export function createBubbleRing(
bubble: LocalBubbleSnapshot,
localPosition: THREE.Vector3,
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
): THREE.LineLoop {
): SceneNode {
const ring = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)),
new THREE.LineBasicMaterial({
@@ -69,11 +71,11 @@ export function createBubbleRing(
}),
);
ring.position.copy(localPosition);
return ring;
return createSceneNode(ring);
}
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
return new THREE.Mesh(
export function createClaimMesh(claim: ClaimSnapshot): SceneNode {
return createSceneNode(new THREE.Mesh(
new THREE.ConeGeometry(9, 20, 4),
new THREE.MeshStandardMaterial({
color: claim.state === "active" ? 0xff7f50 : 0xff5b5b,
@@ -81,11 +83,11 @@ export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
roughness: 0.4,
metalness: 0.28,
}),
);
));
}
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh {
return new THREE.Mesh(
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): SceneNode {
return createSceneNode(new THREE.Mesh(
new THREE.TorusKnotGeometry(7, 2.2, 54, 8),
new THREE.MeshStandardMaterial({
color: site.state === "completed" ? 0x46d37f : 0x9df29c,
@@ -93,10 +95,10 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THRE
roughness: 0.34,
metalness: 0.48,
}),
);
));
}
export function createStarCluster(system: SystemSnapshot): THREE.Group {
export function createStarCluster(system: SystemSnapshot): SceneNode {
const root = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const offsets = system.starCount > 1
@@ -123,22 +125,22 @@ export function createStarCluster(system: SystemSnapshot): THREE.Group {
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 phaseDegrees = (index / 120) * 360;
return computePlanetLocalPosition(planet, 0, phaseDegrees);
});
return new THREE.LineLoop(
return createSceneNode(new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
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 ring = new THREE.Mesh(
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.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
return ring;
return createSceneNode(ring);
}
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;
}
export function createStationMesh(station: StationSnapshot): THREE.Mesh {
export function createStationMesh(station: StationSnapshot): SceneNode {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
);
mesh.rotation.x = Math.PI / 2;
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);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
@@ -212,7 +214,7 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number,
}),
);
mesh.position.copy(toThreeVector(ship.localPosition));
return mesh;
return createSceneNode(mesh);
}
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");
canvas.width = 64;
canvas.height = 64;
@@ -356,7 +358,7 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
}));
sprite.scale.setScalar(size);
sprite.visible = false;
return sprite;
return createSceneNode(sprite);
}
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.height = 160;
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,
transparent: true,
depthWrite: false,
depthTest: false,
}));
sprite.scale.set(520, 160, 1);
sprite.visible = false;
})));
sprite.object.scale.set(520, 160, 1);
sprite.setVisible(false);
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");
canvas.width = 128;
canvas.height = 128;
@@ -412,9 +414,9 @@ export function createShellReticle(documentRef: Document, color: string, size: n
blending: THREE.AdditiveBlending,
fog: false,
});
const sprite = new THREE.Sprite(material);
sprite.scale.setScalar(size);
sprite.visible = false;
sprite.renderOrder = 1000;
const sprite = createSceneNode(new THREE.Sprite(material));
sprite.setScaleScalar(size);
sprite.setVisible(false);
sprite.setRenderOrder(1000);
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,
ConstructionSiteVisual,
NodeVisual,
OrbitLineVisual,
PlanetVisual,
PresentationEntry,
Selectable,
@@ -39,6 +40,7 @@ import {
computePlanetLocalPosition,
toThreeVector,
} from "./viewerMath";
import { getAnimatedShipLocalPosition } from "./viewerPresentation";
import {
createBubbleRing,
createClaimMesh,
@@ -55,10 +57,18 @@ import {
createSystemSummaryVisual,
createTacticalIcon,
} from "./viewerSceneFactory";
import {
createSceneNode,
rawObject,
registerSelectableDescendants,
registerSelectableTarget,
} from "./viewerScenePrimitives";
import type { SceneNode } from "./viewerScenePrimitives";
interface SceneSyncContext {
documentRef: Document;
worldGeneratedAtUtc?: string;
worldOrbitalTimeSeconds?: number;
orbitalSimulationSpeed: number;
worldSeed: number;
worldTimeSyncMs: number;
systemGroup: THREE.Group;
@@ -74,7 +84,7 @@ interface SceneSyncContext {
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
planetVisuals: PlanetVisual[];
orbitLines: THREE.Object3D[];
orbitLines: OrbitLineVisual[];
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
nodeVisuals: Map<string, NodeVisual>;
@@ -83,8 +93,8 @@ interface SceneSyncContext {
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
shipVisuals: Map<string, ShipVisual>;
registerPresentation: (
detail: THREE.Object3D,
icon: THREE.Sprite,
detail: SceneNode,
icon: SceneNode,
hideDetailInUniverse: boolean,
hideIconInUniverse?: boolean,
systemId?: string,
@@ -111,8 +121,8 @@ interface SceneSyncContext {
}
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
const worldTimeSeconds = context.worldGeneratedAtUtc
? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97)
const worldTimeSeconds = context.worldOrbitalTimeSeconds !== undefined
? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed)
: 0;
context.systemGroup.clear();
@@ -124,9 +134,9 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
context.systemSummaryVisuals.clear();
for (const system of systems) {
const root = new THREE.Group();
root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z);
const detailGroup = new THREE.Group();
const root = createSceneNode(new THREE.Group());
root.setPosition(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z));
const detailGroup = createSceneNode(new THREE.Group());
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const starCluster = createStarCluster(system);
@@ -136,7 +146,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
context.documentRef,
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);
context.registerPresentation(starCluster, systemIcon, true);
context.systemVisuals.set(system.id, {
@@ -150,18 +160,14 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
galaxyPosition: toThreeVector(system.galaxyPosition),
});
context.systemSummaryVisuals.set(system.id, summaryVisual);
starCluster.traverse((child) => {
if (child instanceof THREE.Mesh) {
context.selectableTargets.set(child, { kind: "system", id: system.id });
}
});
context.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
context.selectableTargets.set(shellReticle, { kind: "system", id: system.id });
registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh);
registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id });
registerSelectableTarget(context.selectableTargets, shellReticle, { kind: "system", id: system.id });
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet);
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.MeshStandardMaterial({
color: planet.color,
@@ -169,13 +175,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
metalness: 0.08,
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));
planetIcon.position.copy(planetMesh.position);
planetIcon.setPosition(rawObject(planetMesh).position.clone());
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) {
ring.position.copy(planetMesh.position);
ring.setPosition(rawObject(planetMesh).position.clone());
}
const moons = createMoonVisuals(planet, context.worldSeed);
detailGroup.add(orbit, planetMesh, planetIcon);
@@ -183,23 +189,35 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
detailGroup.add(ring);
}
for (const moon of moons) {
moon.orbit.position.copy(planetMesh.position);
moon.mesh.position.copy(planetMesh.position);
moon.systemId = system.id;
moon.planetIndex = planetIndex;
moon.orbit.setPosition(rawObject(planetMesh).position.clone());
moon.mesh.setPosition(rawObject(planetMesh).position.clone());
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.orbitLines.push(orbit);
context.orbitLines.push({
line: orbit,
systemId: system.id,
kind: "planet",
planetIndex,
});
context.registerPresentation(planetMesh, planetIcon, true, true, system.id);
if (ring) {
context.registerPresentation(ring, planetIcon, true, true, system.id);
}
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
context.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
context.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
registerSelectableTarget(context.selectableTargets, planetMesh, { 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 icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18);
const localPosition = toThreeVector(node.localPosition);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
context.spatialNodeVisuals.set(node.id, {
id: node.id,
systemId: node.systemId,
@@ -221,10 +239,10 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
kind: node.kind,
localPosition,
});
context.spatialNodeGroup.add(mesh, icon);
context.spatialNodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "spatial-node", id: node.id });
context.selectableTargets.set(icon, { kind: "spatial-node", id: node.id });
registerSelectableTarget(context.selectableTargets, mesh, { 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 };
context.setBubbleVisualState(visual, bubble);
context.bubbleVisuals.set(bubble.id, visual);
context.bubbleGroup.add(mesh);
context.selectableTargets.set(mesh, { kind: "bubble", id: bubble.id });
context.bubbleGroup.add(rawObject(mesh));
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) {
const mesh = createNodeMesh(node);
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 anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
const orbital = context.deriveNodeOrbital(node, anchor);
@@ -265,10 +283,10 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
orbitPhase: orbital.phase,
orbitInclination: orbital.inclination,
});
context.nodeGroup.add(mesh, icon);
context.nodeGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "node", id: node.id });
context.selectableTargets.set(icon, { kind: "node", id: node.id });
registerSelectableTarget(context.selectableTargets, mesh, { 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) {
const mesh = createStationMesh(station);
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 anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
@@ -294,10 +312,10 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
orbitInclination: orbital.inclination,
localPosition,
});
context.stationGroup.add(mesh, icon);
context.stationGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, station.systemId);
context.selectableTargets.set(mesh, { kind: "station", id: station.id });
context.selectableTargets.set(icon, { kind: "station", id: station.id });
registerSelectableTarget(context.selectableTargets, mesh, { 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 mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
context.claimVisuals.set(claim.id, {
id: claim.id,
nodeId: claim.nodeId,
@@ -319,10 +337,10 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
icon,
localPosition,
});
context.claimGroup.add(mesh, icon);
context.claimGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, claim.systemId);
context.selectableTargets.set(mesh, { kind: "claim", id: claim.id });
context.selectableTargets.set(icon, { kind: "claim", id: claim.id });
registerSelectableTarget(context.selectableTargets, mesh, { 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 mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
mesh.setPosition(localPosition);
icon.setPosition(localPosition);
context.constructionSiteVisuals.set(site.id, {
id: site.id,
nodeId: site.nodeId,
@@ -344,10 +362,10 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
icon,
localPosition,
});
context.constructionSiteGroup.add(mesh, icon);
context.constructionSiteGroup.add(rawObject(mesh), rawObject(icon));
context.registerPresentation(mesh, icon, true, true, site.systemId);
context.selectableTargets.set(mesh, { kind: "construction-site", id: site.id });
context.selectableTargets.set(icon, { kind: "construction-site", id: site.id });
registerSelectableTarget(context.selectableTargets, mesh, { 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 icon = createTacticalIcon(context.documentRef, shipColor, 18);
const position = toThreeVector(ship.localPosition);
icon.position.copy(position);
icon.material.color.set(shipColor);
context.shipGroup.add(mesh, icon);
context.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
context.selectableTargets.set(icon, { kind: "ship", id: ship.id });
icon.setPosition(position);
icon.setColor(shipColor);
context.shipGroup.add(rawObject(mesh), rawObject(icon));
registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id });
registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id });
context.registerPresentation(mesh, icon, true, true, ship.systemId);
context.shipVisuals.set(ship.id, {
systemId: ship.systemId,
@@ -390,9 +408,9 @@ export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: Spatial
visual.systemId = node.systemId;
visual.kind = node.kind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(context.spatialNodeColor(node.kind));
visual.mesh.setPosition(visual.localPosition);
visual.icon.setPosition(visual.localPosition);
visual.mesh.setColor(context.spatialNodeColor(node.kind));
}
}
@@ -406,8 +424,8 @@ export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: Local
visual.systemId = bubble.systemId;
visual.radius = bubble.radius;
visual.localPosition.copy(context.resolveBubblePosition(bubble));
visual.mesh.position.copy(visual.localPosition);
visual.mesh.scale.setScalar(Math.max(bubble.radius, 60));
visual.mesh.setPosition(visual.localPosition);
visual.mesh.setScaleScalar(Math.max(bubble.radius, 60));
context.setBubbleVisualState(visual, bubble);
}
}
@@ -427,7 +445,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
visual.orbitRadius = orbital.radius;
visual.orbitPhase = orbital.phase;
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.orbitPhase = orbital.phase;
visual.orbitInclination = orbital.inclination;
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(station.color);
material.emissive = new THREE.Color(station.color).multiplyScalar(0.1);
visual.mesh.setColor(station.color);
visual.mesh.setEmissive(station.color, 0.1);
}
}
@@ -460,11 +477,10 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
material.emissive.set(claim.state === "active" ? "#ffb27d" : "#7a2020");
visual.mesh.setPosition(visual.localPosition);
visual.icon.setPosition(visual.localPosition);
visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020");
}
}
@@ -477,11 +493,10 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(site.state === "completed" ? "#46d37f" : "#9df29c");
visual.mesh.scale.setScalar(0.75 + site.progress * 0.35);
visual.mesh.setPosition(visual.localPosition);
visual.icon.setPosition(visual.localPosition);
visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c");
visual.mesh.setScaleScalar(0.75 + site.progress * 0.35);
}
}
@@ -493,16 +508,15 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
}
visual.systemId = ship.systemId;
visual.startPosition.copy(visual.authoritativePosition);
visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition));
visual.velocity.copy(toThreeVector(ship.localVelocity));
visual.receivedAtMs = performance.now();
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
const shipColor = context.shipPresentationColor(ship);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(shipColor);
material.emissive.set(new THREE.Color(shipColor).multiplyScalar(0.18));
visual.icon.material.color.set(shipColor);
visual.mesh.setColor(shipColor);
visual.mesh.setEmissive(shipColor, 0.18);
visual.icon.setColor(shipColor);
}
}

View File

@@ -1,4 +1,4 @@
import type { SystemSnapshot } from "./contracts";
import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts";
import type {
CameraMode,
OrbitalAnchor,
@@ -214,3 +214,205 @@ export function renderSystemDetails(
${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,
sequence: snapshot.sequence,
tickIntervalMs: snapshot.tickIntervalMs,
orbitalTimeSeconds: snapshot.orbitalTimeSeconds,
orbitalSimulation: snapshot.orbitalSimulation,
generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
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 {
world.sequence = delta.sequence;
world.tickIntervalMs = delta.tickIntervalMs;
world.orbitalTimeSeconds = delta.orbitalTimeSeconds;
world.orbitalSimulation = delta.orbitalSimulation;
world.generatedAtUtc = delta.generatedAtUtc;
world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
"bulk-liquid": 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",
@@ -22,7 +22,7 @@
"radius": 20,
"dockingCapacity": 4,
"storage": { "container": 1200, "manufactured": 800 },
"modules": ["habitat-ring", "docking-clamps", "container-bay"]
"modules": ["habitat-ring", "container-bay"]
},
{
"id": "refinery",
@@ -32,7 +32,7 @@
"radius": 24,
"dockingCapacity": 3,
"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",
@@ -52,7 +52,7 @@
"radius": 24,
"dockingCapacity": 3,
"storage": { "manufactured": 2200, "container": 1600 },
"modules": ["fabricator-array", "fabricator-array", "container-bay", "docking-clamps"]
"modules": ["fabricator-array", "fabricator-array", "container-bay"]
},
{
"id": "shipyard",
@@ -62,7 +62,7 @@
"radius": 28,
"dockingCapacity": 5,
"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",
@@ -82,6 +82,6 @@
"radius": 34,
"dockingCapacity": 0,
"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",
"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",
"label": "Volatile Gas",
@@ -53,6 +143,12 @@
"storage": "bulk-liquid",
"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",
"label": "Water",

View File

@@ -13,6 +13,13 @@
{ "itemId": "refined-metals", "amount": 30 }
]
},
{
"moduleId": "container-bay",
"duration": 10,
"inputs": [
{ "itemId": "refined-metals", "amount": 26 }
]
},
{
"moduleId": "fuel-processor",
"duration": 14,
@@ -26,5 +33,36 @@
"inputs": [
{ "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",
"summary": "Standardized freight racks."
},
{
"id": "docking-clamps",
"label": "Docking Clamps",
"category": "dock",
"summary": "Docking collar and transfer arms."
},
{
"id": "dock-bay-small",
"label": "Small Dock Bay",
@@ -113,12 +107,30 @@
"category": "production",
"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",
"label": "Power Core",
"category": "energy",
"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",
"label": "Liquid Tank",

View File

@@ -41,6 +41,31 @@
{ "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",
"label": "Water Reclamation",
@@ -143,6 +168,400 @@
{ "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",
"label": "Trade Hub Assembly",

View File

@@ -42,7 +42,7 @@
"hullColor": "#314562",
"size": 10,
"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",
@@ -70,13 +70,13 @@
"ftlSpeed": 2600,
"spoolTime": 3.3,
"cargoCapacity": 180,
"cargoKind": "container",
"cargoItemId": "drone-parts",
"cargoKind": "bulk-liquid",
"cargoItemId": "energy-cell",
"color": "#b0ff8d",
"hullColor": "#365f2a",
"size": 8,
"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",
@@ -93,7 +93,7 @@
"hullColor": "#2d5d47",
"size": 9,
"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",
@@ -110,7 +110,7 @@
"hullColor": "#68552b",
"size": 6,
"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",
@@ -127,6 +127,6 @@
"hullColor": "#2a5668",
"size": 6,
"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"]
}
]