Refactor modular startup and viewer ship debugging
This commit is contained in:
@@ -74,6 +74,10 @@ public sealed record ResourceNodeDelta(
|
|||||||
float MaxOre,
|
float MaxOre,
|
||||||
string ItemId);
|
string ItemId);
|
||||||
|
|
||||||
|
public sealed record InventoryEntry(
|
||||||
|
string ItemId,
|
||||||
|
float Amount);
|
||||||
|
|
||||||
public sealed record StationSnapshot(
|
public sealed record StationSnapshot(
|
||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Label,
|
||||||
@@ -82,8 +86,8 @@ public sealed record StationSnapshot(
|
|||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
string Color,
|
string Color,
|
||||||
int DockedShips,
|
int DockedShips,
|
||||||
float OreStored,
|
float EnergyStored,
|
||||||
float RefinedStock,
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
string FactionId);
|
string FactionId);
|
||||||
|
|
||||||
public sealed record StationDelta(
|
public sealed record StationDelta(
|
||||||
@@ -94,8 +98,8 @@ public sealed record StationDelta(
|
|||||||
Vector3Dto LocalPosition,
|
Vector3Dto LocalPosition,
|
||||||
string Color,
|
string Color,
|
||||||
int DockedShips,
|
int DockedShips,
|
||||||
float OreStored,
|
float EnergyStored,
|
||||||
float RefinedStock,
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
string FactionId);
|
string FactionId);
|
||||||
|
|
||||||
public sealed record ShipSnapshot(
|
public sealed record ShipSnapshot(
|
||||||
@@ -111,9 +115,9 @@ public sealed record ShipSnapshot(
|
|||||||
string? OrderKind,
|
string? OrderKind,
|
||||||
string DefaultBehaviorKind,
|
string DefaultBehaviorKind,
|
||||||
string ControllerTaskKind,
|
string ControllerTaskKind,
|
||||||
float Cargo,
|
|
||||||
float CargoCapacity,
|
float CargoCapacity,
|
||||||
string? CargoItemId,
|
float EnergyStored,
|
||||||
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
float Health,
|
float Health,
|
||||||
IReadOnlyList<string> History);
|
IReadOnlyList<string> History);
|
||||||
@@ -131,9 +135,9 @@ public sealed record ShipDelta(
|
|||||||
string? OrderKind,
|
string? OrderKind,
|
||||||
string DefaultBehaviorKind,
|
string DefaultBehaviorKind,
|
||||||
string ControllerTaskKind,
|
string ControllerTaskKind,
|
||||||
float Cargo,
|
|
||||||
float CargoCapacity,
|
float CargoCapacity,
|
||||||
string? CargoItemId,
|
float EnergyStored,
|
||||||
|
IReadOnlyList<InventoryEntry> Inventory,
|
||||||
string FactionId,
|
string FactionId,
|
||||||
float Health,
|
float Health,
|
||||||
IReadOnlyList<string> History);
|
IReadOnlyList<string> History);
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ public sealed class ResourceNodeDefinition
|
|||||||
public int ShardCount { get; set; }
|
public int ShardCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed class ItemDefinition
|
||||||
|
{
|
||||||
|
public required string Id { get; set; }
|
||||||
|
public required string Label { get; set; }
|
||||||
|
public required string Storage { get; set; }
|
||||||
|
public string Summary { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
public sealed class PlanetDefinition
|
public sealed class PlanetDefinition
|
||||||
{
|
{
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
@@ -95,6 +103,7 @@ public sealed class ShipDefinition
|
|||||||
public required string HullColor { get; set; }
|
public required string HullColor { get; set; }
|
||||||
public float Size { get; set; }
|
public float Size { get; set; }
|
||||||
public float MaxHealth { get; set; }
|
public float MaxHealth { get; set; }
|
||||||
|
public List<string> Modules { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ConstructibleDefinition
|
public sealed class ConstructibleDefinition
|
||||||
@@ -105,6 +114,8 @@ public sealed class ConstructibleDefinition
|
|||||||
public required string Color { get; set; }
|
public required string Color { get; set; }
|
||||||
public float Radius { get; set; }
|
public float Radius { get; set; }
|
||||||
public int DockingCapacity { get; set; }
|
public int DockingCapacity { get; set; }
|
||||||
|
public Dictionary<string, float> Storage { get; set; } = new(StringComparer.Ordinal);
|
||||||
|
public List<string> Modules { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ScenarioDefinition
|
public sealed class ScenarioDefinition
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class SimulationWorld
|
|||||||
public required List<ShipRuntime> Ships { get; init; }
|
public required List<ShipRuntime> Ships { get; init; }
|
||||||
public required List<FactionRuntime> Factions { get; init; }
|
public required List<FactionRuntime> Factions { get; init; }
|
||||||
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
|
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
|
||||||
|
public required Dictionary<string, ItemDefinition> ItemDefinitions { get; init; }
|
||||||
public int TickIntervalMs { get; init; } = 200;
|
public int TickIntervalMs { get; init; } = 200;
|
||||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||||
}
|
}
|
||||||
@@ -42,8 +43,8 @@ public sealed class StationRuntime
|
|||||||
public required ConstructibleDefinition Definition { get; init; }
|
public required ConstructibleDefinition Definition { get; init; }
|
||||||
public required Vector3 Position { get; init; }
|
public required Vector3 Position { get; init; }
|
||||||
public required string FactionId { get; init; }
|
public required string FactionId { get; init; }
|
||||||
public float OreStored { get; set; }
|
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
||||||
public float RefinedStock { get; set; }
|
public float EnergyStored { get; set; }
|
||||||
public float ProcessTimer { get; set; }
|
public float ProcessTimer { get; set; }
|
||||||
public HashSet<string> DockedShipIds { get; } = [];
|
public HashSet<string> DockedShipIds { get; } = [];
|
||||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||||
@@ -63,7 +64,8 @@ public sealed class ShipRuntime
|
|||||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||||
public required ControllerTaskRuntime ControllerTask { get; set; }
|
public required ControllerTaskRuntime ControllerTask { get; set; }
|
||||||
public float ActionTimer { get; set; }
|
public float ActionTimer { get; set; }
|
||||||
public float Cargo { get; set; }
|
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
||||||
|
public float EnergyStored { get; set; }
|
||||||
public string? DockedStationId { get; set; }
|
public string? DockedStationId { get; set; }
|
||||||
public float Health { get; set; }
|
public float Health { get; set; }
|
||||||
public List<string> History { get; } = [];
|
public List<string> History { get; } = [];
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ public sealed class ScenarioLoader
|
|||||||
private const string DefaultFactionId = "sol-dominion";
|
private const string DefaultFactionId = "sol-dominion";
|
||||||
private const int TargetSystemCount = 160;
|
private const int TargetSystemCount = 160;
|
||||||
private const int WorldSeed = 1;
|
private const int WorldSeed = 1;
|
||||||
private const float MinimumFactionCredits = 240f;
|
private const float MinimumFactionCredits = 0f;
|
||||||
private const float MinimumRefineryOre = 60f;
|
private const float MinimumRefineryOre = 0f;
|
||||||
private const float MinimumRefineryStock = 40f;
|
private const float MinimumRefineryStock = 0f;
|
||||||
private const float MinimumShipyardStock = 180f;
|
private const float MinimumShipyardStock = 0f;
|
||||||
private const float MinimumSystemSeparation = 3200f;
|
private const float MinimumSystemSeparation = 3200f;
|
||||||
private static readonly string[] GeneratedSystemNames =
|
private static readonly string[] GeneratedSystemNames =
|
||||||
[
|
[
|
||||||
@@ -87,10 +87,12 @@ public sealed class ScenarioLoader
|
|||||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||||
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
||||||
|
var items = Read<List<ItemDefinition>>("items.json");
|
||||||
var balance = Read<BalanceDefinition>("balance.json");
|
var balance = Read<BalanceDefinition>("balance.json");
|
||||||
|
|
||||||
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
||||||
var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
||||||
|
var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
||||||
var systemRuntimes = systems
|
var systemRuntimes = systems
|
||||||
.Select((definition) => new SystemRuntime
|
.Select((definition) => new SystemRuntime
|
||||||
{
|
{
|
||||||
@@ -138,14 +140,18 @@ public sealed class ScenarioLoader
|
|||||||
Definition = definition,
|
Definition = definition,
|
||||||
Position = ResolveStationPosition(system, plan, balance),
|
Position = ResolveStationPosition(system, plan, balance),
|
||||||
FactionId = plan.FactionId ?? DefaultFactionId,
|
FactionId = plan.FactionId ?? DefaultFactionId,
|
||||||
OreStored = 0f,
|
|
||||||
RefinedStock = 0f,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var station in stations)
|
||||||
|
{
|
||||||
|
station.Inventory["gas"] = 320f;
|
||||||
|
}
|
||||||
|
|
||||||
var refinery = stations.FirstOrDefault((station) =>
|
var refinery = stations.FirstOrDefault((station) =>
|
||||||
station.Definition.Category == "refining" && station.SystemId == scenario.MiningDefaults.RefinerySystemId)
|
HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank") &&
|
||||||
?? stations.FirstOrDefault((station) => station.Definition.Category == "refining");
|
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
|
||||||
|
?? stations.FirstOrDefault((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank"));
|
||||||
|
|
||||||
var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
|
var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
|
||||||
(route) => route.SystemId,
|
(route) => route.SystemId,
|
||||||
@@ -177,6 +183,13 @@ public sealed class ScenarioLoader
|
|||||||
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold },
|
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold },
|
||||||
Health = definition.MaxHealth,
|
Health = definition.MaxHealth,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
shipsRuntime[^1].Inventory["gas"] = definition.Id switch
|
||||||
|
{
|
||||||
|
"constructor" => 90f,
|
||||||
|
"miner" => 90f,
|
||||||
|
_ => 120f,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +207,7 @@ public sealed class ScenarioLoader
|
|||||||
Ships = shipsRuntime,
|
Ships = shipsRuntime,
|
||||||
Factions = factions,
|
Factions = factions,
|
||||||
ShipDefinitions = shipDefinitions,
|
ShipDefinitions = shipDefinitions,
|
||||||
|
ItemDefinitions = itemDefinitions,
|
||||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -755,29 +769,32 @@ public sealed class ScenarioLoader
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var refineries = ownedStations
|
var refineries = ownedStations
|
||||||
.Where((station) => station.Definition.Category == "refining")
|
.Where((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
if (refineries.Count > 0)
|
if (refineries.Count > 0)
|
||||||
{
|
{
|
||||||
foreach (var refinery in refineries)
|
foreach (var refinery in refineries)
|
||||||
{
|
{
|
||||||
refinery.RefinedStock = MathF.Max(refinery.RefinedStock, MinimumRefineryStock);
|
refinery.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refined-metals"), MinimumRefineryStock);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (refineries.All((station) => station.OreStored < MinimumRefineryOre))
|
if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
|
||||||
{
|
{
|
||||||
refineries[0].OreStored = MinimumRefineryOre;
|
refineries[0].Inventory["ore"] = MinimumRefineryOre;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard"))
|
foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard"))
|
||||||
{
|
{
|
||||||
shipyard.RefinedStock = MathF.Max(shipyard.RefinedStock, MinimumShipyardStock);
|
shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
||||||
|
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
||||||
|
|
||||||
private static string ToFactionLabel(string factionId)
|
private static string ToFactionLabel(string factionId)
|
||||||
{
|
{
|
||||||
return string.Join(" ",
|
return string.Join(" ",
|
||||||
@@ -801,7 +818,7 @@ public sealed class ScenarioLoader
|
|||||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||||
StationRuntime? refinery)
|
StationRuntime? refinery)
|
||||||
{
|
{
|
||||||
if (definition.Role == "mining" && refinery is not null)
|
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
@@ -812,7 +829,7 @@ public sealed class ScenarioLoader
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (definition.Role == "military" && patrolRoutes.TryGetValue(systemId, out var route))
|
if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route))
|
||||||
{
|
{
|
||||||
return new DefaultBehaviorRuntime
|
return new DefaultBehaviorRuntime
|
||||||
{
|
{
|
||||||
@@ -863,5 +880,11 @@ public sealed class ScenarioLoader
|
|||||||
: raw;
|
: raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasModules(ConstructibleDefinition definition, params string[] modules) =>
|
||||||
|
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
|
||||||
|
|
||||||
|
private static bool HasModules(ShipDefinition definition, params string[] modules) =>
|
||||||
|
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
|
||||||
|
|
||||||
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
|
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
|
using SpaceGame.Simulation.Api.Data;
|
||||||
using SpaceGame.Simulation.Api.Contracts;
|
using SpaceGame.Simulation.Api.Contracts;
|
||||||
|
|
||||||
namespace SpaceGame.Simulation.Api.Simulation;
|
namespace SpaceGame.Simulation.Api.Simulation;
|
||||||
|
|
||||||
public sealed class SimulationEngine
|
public sealed class SimulationEngine
|
||||||
{
|
{
|
||||||
|
private const float ShipFuelToEnergyRatio = 12f;
|
||||||
|
private const float StationFuelToEnergyRatio = 18f;
|
||||||
|
private const float CapacitorEnergyPerModule = 120f;
|
||||||
|
private const float StationEnergyPerPowerCore = 480f;
|
||||||
|
private const float ShipFuelPerReactor = 100f;
|
||||||
|
private const float StationFuelPerTank = 500f;
|
||||||
|
|
||||||
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
|
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
|
||||||
{
|
{
|
||||||
var events = new List<SimulationEventRecord>();
|
var events = new List<SimulationEventRecord>();
|
||||||
|
|
||||||
|
UpdateStationPower(world, deltaSeconds, events);
|
||||||
UpdateStations(world, deltaSeconds, events);
|
UpdateStations(world, deltaSeconds, events);
|
||||||
|
|
||||||
foreach (var ship in world.Ships)
|
foreach (var ship in world.Ships)
|
||||||
@@ -17,6 +26,7 @@ public sealed class SimulationEngine
|
|||||||
var previousBehavior = ship.DefaultBehavior.Kind;
|
var previousBehavior = ship.DefaultBehavior.Kind;
|
||||||
var previousTask = ship.ControllerTask.Kind;
|
var previousTask = ship.ControllerTask.Kind;
|
||||||
|
|
||||||
|
UpdateShipPower(ship, world, deltaSeconds, events);
|
||||||
RefreshControlLayers(ship);
|
RefreshControlLayers(ship);
|
||||||
PlanControllerTask(ship, world);
|
PlanControllerTask(ship, world);
|
||||||
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
||||||
@@ -90,8 +100,8 @@ public sealed class SimulationEngine
|
|||||||
station.LocalPosition,
|
station.LocalPosition,
|
||||||
station.Color,
|
station.Color,
|
||||||
station.DockedShips,
|
station.DockedShips,
|
||||||
station.OreStored,
|
station.EnergyStored,
|
||||||
station.RefinedStock,
|
station.Inventory,
|
||||||
station.FactionId)).ToList(),
|
station.FactionId)).ToList(),
|
||||||
world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot(
|
world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot(
|
||||||
ship.Id,
|
ship.Id,
|
||||||
@@ -106,9 +116,9 @@ public sealed class SimulationEngine
|
|||||||
ship.OrderKind,
|
ship.OrderKind,
|
||||||
ship.DefaultBehaviorKind,
|
ship.DefaultBehaviorKind,
|
||||||
ship.ControllerTaskKind,
|
ship.ControllerTaskKind,
|
||||||
ship.Cargo,
|
|
||||||
ship.CargoCapacity,
|
ship.CargoCapacity,
|
||||||
ship.CargoItemId,
|
ship.EnergyStored,
|
||||||
|
ship.Inventory,
|
||||||
ship.FactionId,
|
ship.FactionId,
|
||||||
ship.Health,
|
ship.Health,
|
||||||
ship.History)).ToList(),
|
ship.History)).ToList(),
|
||||||
@@ -222,7 +232,7 @@ public sealed class SimulationEngine
|
|||||||
$"{node.SystemId}|{node.OreRemaining:0.###}";
|
$"{node.SystemId}|{node.OreRemaining:0.###}";
|
||||||
|
|
||||||
private static string BuildStationSignature(StationRuntime station) =>
|
private static string BuildStationSignature(StationRuntime station) =>
|
||||||
$"{station.SystemId}|{station.OreStored:0.###}|{station.RefinedStock:0.###}|{station.DockedShipIds.Count}";
|
$"{station.SystemId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}";
|
||||||
|
|
||||||
private static string BuildShipSignature(ShipRuntime ship) =>
|
private static string BuildShipSignature(ShipRuntime ship) =>
|
||||||
string.Join("|",
|
string.Join("|",
|
||||||
@@ -240,9 +250,18 @@ public sealed class SimulationEngine
|
|||||||
ship.Order?.Kind ?? "none",
|
ship.Order?.Kind ?? "none",
|
||||||
ship.DefaultBehavior.Kind,
|
ship.DefaultBehavior.Kind,
|
||||||
ship.ControllerTask.Kind,
|
ship.ControllerTask.Kind,
|
||||||
ship.Cargo.ToString("0.###"),
|
GetShipCargoAmount(ship).ToString("0.###"),
|
||||||
|
GetInventoryAmount(ship.Inventory, "gas").ToString("0.###"),
|
||||||
|
ship.EnergyStored.ToString("0.###"),
|
||||||
ship.Health.ToString("0.###"));
|
ship.Health.ToString("0.###"));
|
||||||
|
|
||||||
|
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||||
|
string.Join(",",
|
||||||
|
inventory
|
||||||
|
.Where((entry) => entry.Value > 0.001f)
|
||||||
|
.OrderBy((entry) => entry.Key, StringComparer.Ordinal)
|
||||||
|
.Select((entry) => $"{entry.Key}:{entry.Value:0.###}"));
|
||||||
|
|
||||||
private static string BuildFactionSignature(FactionRuntime faction) =>
|
private static string BuildFactionSignature(FactionRuntime faction) =>
|
||||||
$"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}";
|
$"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}";
|
||||||
|
|
||||||
@@ -263,8 +282,8 @@ public sealed class SimulationEngine
|
|||||||
ToDto(station.Position),
|
ToDto(station.Position),
|
||||||
station.Definition.Color,
|
station.Definition.Color,
|
||||||
station.DockedShipIds.Count,
|
station.DockedShipIds.Count,
|
||||||
station.OreStored,
|
station.EnergyStored,
|
||||||
station.RefinedStock,
|
ToInventoryEntries(station.Inventory),
|
||||||
station.FactionId);
|
station.FactionId);
|
||||||
|
|
||||||
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
|
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
|
||||||
@@ -280,13 +299,20 @@ public sealed class SimulationEngine
|
|||||||
ship.Order?.Kind,
|
ship.Order?.Kind,
|
||||||
ship.DefaultBehavior.Kind,
|
ship.DefaultBehavior.Kind,
|
||||||
ship.ControllerTask.Kind,
|
ship.ControllerTask.Kind,
|
||||||
ship.Cargo,
|
|
||||||
ship.Definition.CargoCapacity,
|
ship.Definition.CargoCapacity,
|
||||||
ship.Definition.CargoItemId,
|
ship.EnergyStored,
|
||||||
|
ToInventoryEntries(ship.Inventory),
|
||||||
ship.FactionId,
|
ship.FactionId,
|
||||||
ship.Health,
|
ship.Health,
|
||||||
ship.History.ToList());
|
ship.History.ToList());
|
||||||
|
|
||||||
|
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
|
||||||
|
inventory
|
||||||
|
.Where((entry) => entry.Value > 0.001f)
|
||||||
|
.OrderBy((entry) => entry.Key, StringComparer.Ordinal)
|
||||||
|
.Select((entry) => new InventoryEntry(entry.Key, entry.Value))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
private static FactionDelta ToFactionDelta(FactionRuntime faction) => new(
|
private static FactionDelta ToFactionDelta(FactionRuntime faction) => new(
|
||||||
faction.Id,
|
faction.Id,
|
||||||
faction.Label,
|
faction.Label,
|
||||||
@@ -332,11 +358,17 @@ public sealed class SimulationEngine
|
|||||||
{
|
{
|
||||||
foreach (var station in world.Stations)
|
foreach (var station in world.Stations)
|
||||||
{
|
{
|
||||||
if (station.Definition.Category != "refining" || station.OreStored < 60f)
|
if (!HasRefineryCapability(station.Definition) || GetInventoryAmount(station.Inventory, "ore") < 60f)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
station.ProcessTimer = 0f;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
station.ProcessTimer += deltaSeconds;
|
station.ProcessTimer += deltaSeconds;
|
||||||
if (station.ProcessTimer < 8f)
|
if (station.ProcessTimer < 8f)
|
||||||
{
|
{
|
||||||
@@ -344,8 +376,8 @@ public sealed class SimulationEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
station.ProcessTimer = 0f;
|
station.ProcessTimer = 0f;
|
||||||
station.OreStored -= 60f;
|
RemoveInventory(station.Inventory, "ore", 60f);
|
||||||
station.RefinedStock += 60f;
|
AddInventory(station.Inventory, "refined-metals", 60f);
|
||||||
events.Add(new SimulationEventRecord("station", station.Id, "refined", $"{station.Definition.Label} refined 60 ore", DateTimeOffset.UtcNow));
|
events.Add(new SimulationEventRecord("station", station.Id, "refined", $"{station.Definition.Label} refined 60 ore", DateTimeOffset.UtcNow));
|
||||||
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
|
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
|
||||||
if (faction is not null)
|
if (faction is not null)
|
||||||
@@ -356,6 +388,171 @@ public sealed class SimulationEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasRefineryCapability(ConstructibleDefinition definition) =>
|
||||||
|
definition.Modules.Contains("refinery-stack", StringComparer.Ordinal)
|
||||||
|
&& definition.Modules.Contains("power-core", StringComparer.Ordinal)
|
||||||
|
&& definition.Modules.Contains("liquid-tank", StringComparer.Ordinal)
|
||||||
|
&& definition.Modules.Contains("gas-tank", StringComparer.Ordinal)
|
||||||
|
&& definition.Storage.ContainsKey("bulk-solid")
|
||||||
|
&& definition.Storage.ContainsKey("manufactured");
|
||||||
|
|
||||||
|
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
|
||||||
|
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
|
||||||
|
|
||||||
|
private static void UpdateStationPower(
|
||||||
|
SimulationWorld world,
|
||||||
|
float deltaSeconds,
|
||||||
|
ICollection<SimulationEventRecord> events)
|
||||||
|
{
|
||||||
|
foreach (var station in world.Stations)
|
||||||
|
{
|
||||||
|
var previousEnergy = station.EnergyStored;
|
||||||
|
GenerateStationEnergy(station, deltaSeconds);
|
||||||
|
|
||||||
|
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "gas") <= 0.01f)
|
||||||
|
{
|
||||||
|
events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateShipPower(
|
||||||
|
ShipRuntime ship,
|
||||||
|
SimulationWorld world,
|
||||||
|
float deltaSeconds,
|
||||||
|
ICollection<SimulationEventRecord> events)
|
||||||
|
{
|
||||||
|
var previousEnergy = ship.EnergyStored;
|
||||||
|
GenerateShipEnergy(ship, world, deltaSeconds);
|
||||||
|
|
||||||
|
if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "gas") <= 0.01f)
|
||||||
|
{
|
||||||
|
events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds)
|
||||||
|
{
|
||||||
|
var powerCores = CountModules(station.Definition.Modules, "power-core");
|
||||||
|
var tanks = CountModules(station.Definition.Modules, "gas-tank");
|
||||||
|
if (powerCores <= 0 || tanks <= 0)
|
||||||
|
{
|
||||||
|
station.EnergyStored = 0f;
|
||||||
|
station.Inventory.Remove("gas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var energyCapacity = powerCores * StationEnergyPerPowerCore;
|
||||||
|
var fuelStored = GetInventoryAmount(station.Inventory, "gas");
|
||||||
|
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||||
|
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
||||||
|
{
|
||||||
|
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
||||||
|
station.Inventory["gas"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds);
|
||||||
|
var requiredFuel = generated / StationFuelToEnergyRatio;
|
||||||
|
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
|
||||||
|
var actualGenerated = consumedFuel * StationFuelToEnergyRatio;
|
||||||
|
|
||||||
|
RemoveInventory(station.Inventory, "gas", consumedFuel);
|
||||||
|
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
|
{
|
||||||
|
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
|
||||||
|
var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank");
|
||||||
|
if (reactors <= 0 || capacitors <= 0)
|
||||||
|
{
|
||||||
|
ship.EnergyStored = 0f;
|
||||||
|
ship.Inventory.Remove("gas");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var energyCapacity = capacitors * CapacitorEnergyPerModule;
|
||||||
|
var fuelCapacity = reactors * ShipFuelPerReactor;
|
||||||
|
var fuelStored = GetInventoryAmount(ship.Inventory, "gas");
|
||||||
|
var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored);
|
||||||
|
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
||||||
|
{
|
||||||
|
ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity);
|
||||||
|
ship.Inventory["gas"] = MathF.Min(fuelStored, fuelCapacity);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds);
|
||||||
|
var requiredFuel = generated / ShipFuelToEnergyRatio;
|
||||||
|
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
|
||||||
|
var actualGenerated = consumedFuel * ShipFuelToEnergyRatio;
|
||||||
|
|
||||||
|
RemoveInventory(ship.Inventory, "gas", consumedFuel);
|
||||||
|
ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount)
|
||||||
|
{
|
||||||
|
if (ship.EnergyStored + 0.0001f < amount)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryConsumeStationEnergy(StationRuntime station, float amount)
|
||||||
|
{
|
||||||
|
if (station.EnergyStored + 0.0001f < amount)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
|
||||||
|
modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
|
||||||
|
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
|
||||||
|
|
||||||
|
private static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
||||||
|
{
|
||||||
|
if (amount <= 0f)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
|
||||||
|
{
|
||||||
|
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
|
||||||
|
var removed = MathF.Min(current, amount);
|
||||||
|
var remaining = current - removed;
|
||||||
|
if (remaining <= 0.001f)
|
||||||
|
{
|
||||||
|
inventory.Remove(itemId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
inventory[itemId] = remaining;
|
||||||
|
}
|
||||||
|
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float GetShipCargoAmount(ShipRuntime ship)
|
||||||
|
{
|
||||||
|
var cargoItemId = ship.Definition.CargoItemId;
|
||||||
|
return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId);
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshControlLayers(ShipRuntime ship)
|
private void RefreshControlLayers(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
if (ship.Order is not null && ship.Order.Status == "queued")
|
if (ship.Order is not null && ship.Order.Status == "queued")
|
||||||
@@ -495,6 +692,7 @@ public sealed class SimulationEngine
|
|||||||
switch (task.Kind)
|
switch (task.Kind)
|
||||||
{
|
{
|
||||||
case "idle":
|
case "idle":
|
||||||
|
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
|
||||||
ship.State = "idle";
|
ship.State = "idle";
|
||||||
ship.TargetPosition = ship.Position;
|
ship.TargetPosition = ship.Position;
|
||||||
return "none";
|
return "none";
|
||||||
@@ -529,6 +727,7 @@ public sealed class SimulationEngine
|
|||||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||||
if (distance <= task.Threshold)
|
if (distance <= task.Threshold)
|
||||||
{
|
{
|
||||||
|
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
|
||||||
ship.Position = task.TargetPosition.Value;
|
ship.Position = task.TargetPosition.Value;
|
||||||
ship.TargetPosition = ship.Position;
|
ship.TargetPosition = ship.Position;
|
||||||
ship.SystemId = task.TargetSystemId;
|
ship.SystemId = task.TargetSystemId;
|
||||||
@@ -537,15 +736,18 @@ public sealed class SimulationEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
var speed = ship.Definition.Speed;
|
var speed = ship.Definition.Speed;
|
||||||
|
var energyCost = world.Balance.Energy.MoveDrain * deltaSeconds;
|
||||||
if (ship.SystemId != task.TargetSystemId)
|
if (ship.SystemId != task.TargetSystemId)
|
||||||
{
|
{
|
||||||
ship.State = distance > 800f ? "ftl" : "spooling-ftl";
|
ship.State = distance > 800f ? "ftl" : "spooling-ftl";
|
||||||
speed = ship.Definition.FtlSpeed;
|
speed = ship.Definition.FtlSpeed;
|
||||||
|
energyCost = world.Balance.Energy.WarpDrain * deltaSeconds;
|
||||||
}
|
}
|
||||||
else if (distance > 200f)
|
else if (distance > 200f)
|
||||||
{
|
{
|
||||||
ship.State = distance > 500f ? "warping" : "spooling-warp";
|
ship.State = distance > 500f ? "warping" : "spooling-warp";
|
||||||
speed = ship.Definition.Speed * 4.5f;
|
speed = ship.Definition.Speed * 4.5f;
|
||||||
|
energyCost = world.Balance.Energy.WarpDrain * deltaSeconds;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -553,12 +755,26 @@ public sealed class SimulationEngine
|
|||||||
speed = ship.Definition.Speed;
|
speed = ship.Definition.Speed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!TryConsumeShipEnergy(ship, energyCost))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
{
|
{
|
||||||
|
if (!HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"))
|
||||||
|
{
|
||||||
|
ship.State = "idle";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
var task = ship.ControllerTask;
|
var task = ship.ControllerTask;
|
||||||
var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
|
var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
|
||||||
if (node is null || task.TargetPosition is null)
|
if (node is null || task.TargetPosition is null)
|
||||||
@@ -572,11 +788,25 @@ public sealed class SimulationEngine
|
|||||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||||
if (distance > task.Threshold)
|
if (distance > task.Threshold)
|
||||||
{
|
{
|
||||||
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
ship.State = "mining-approach";
|
ship.State = "mining-approach";
|
||||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
ship.State = "mining";
|
ship.State = "mining";
|
||||||
ship.ActionTimer += deltaSeconds;
|
ship.ActionTimer += deltaSeconds;
|
||||||
if (ship.ActionTimer < 1f)
|
if (ship.ActionTimer < 1f)
|
||||||
@@ -585,16 +815,20 @@ public sealed class SimulationEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
ship.ActionTimer = 0f;
|
ship.ActionTimer = 0f;
|
||||||
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - ship.Cargo);
|
var cargoAmount = GetShipCargoAmount(ship);
|
||||||
|
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount);
|
||||||
mined = MathF.Min(mined, node.OreRemaining);
|
mined = MathF.Min(mined, node.OreRemaining);
|
||||||
ship.Cargo += mined;
|
if (ship.Definition.CargoItemId is not null)
|
||||||
|
{
|
||||||
|
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
|
||||||
|
}
|
||||||
node.OreRemaining -= mined;
|
node.OreRemaining -= mined;
|
||||||
if (node.OreRemaining <= 0f)
|
if (node.OreRemaining <= 0f)
|
||||||
{
|
{
|
||||||
node.OreRemaining = node.MaxOre;
|
node.OreRemaining = node.MaxOre;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ship.Cargo >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
|
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
@@ -612,11 +846,32 @@ public sealed class SimulationEngine
|
|||||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||||
if (distance > task.Threshold)
|
if (distance > task.Threshold)
|
||||||
{
|
{
|
||||||
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
ship.State = "docking-approach";
|
ship.State = "docking-approach";
|
||||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
ship.State = "docking";
|
ship.State = "docking";
|
||||||
ship.ActionTimer += deltaSeconds;
|
ship.ActionTimer += deltaSeconds;
|
||||||
if (ship.ActionTimer < world.Balance.DockingDuration)
|
if (ship.ActionTimer < world.Balance.DockingDuration)
|
||||||
@@ -651,11 +906,23 @@ public sealed class SimulationEngine
|
|||||||
return "none";
|
return "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||||
|
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
ship.TargetPosition = station.Position;
|
ship.TargetPosition = station.Position;
|
||||||
ship.State = "transferring";
|
ship.State = "transferring";
|
||||||
var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
|
var cargoItemId = ship.Definition.CargoItemId;
|
||||||
ship.Cargo -= moved;
|
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
|
||||||
station.OreStored += moved;
|
if (cargoItemId is not null)
|
||||||
|
{
|
||||||
|
RemoveInventory(ship.Inventory, cargoItemId, moved);
|
||||||
|
AddInventory(station.Inventory, cargoItemId, moved);
|
||||||
|
}
|
||||||
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
|
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
|
||||||
if (faction is not null)
|
if (faction is not null)
|
||||||
{
|
{
|
||||||
@@ -663,7 +930,7 @@ public sealed class SimulationEngine
|
|||||||
faction.Credits += moved * 0.4f;
|
faction.Credits += moved * 0.4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ship.Cargo <= 0.01f ? "unloaded" : "none";
|
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||||
@@ -678,6 +945,20 @@ public sealed class SimulationEngine
|
|||||||
|
|
||||||
ship.TargetPosition = task.TargetPosition.Value;
|
ship.TargetPosition = task.TargetPosition.Value;
|
||||||
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
||||||
|
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||||
|
{
|
||||||
|
ship.State = "power-starved";
|
||||||
|
ship.TargetPosition = ship.Position;
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
station?.DockedShipIds.Remove(ship.Id);
|
station?.DockedShipIds.Remove(ship.Id);
|
||||||
ship.DockedStationId = null;
|
ship.DockedStationId = null;
|
||||||
ship.State = "undocking";
|
ship.State = "undocking";
|
||||||
@@ -699,7 +980,7 @@ public sealed class SimulationEngine
|
|||||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||||
{
|
{
|
||||||
case ("travel-to-node", "arrived"):
|
case ("travel-to-node", "arrived"):
|
||||||
ship.DefaultBehavior.Phase = ship.Cargo >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
|
ship.DefaultBehavior.Phase = GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
|
||||||
break;
|
break;
|
||||||
case ("extract", "cargo-full"):
|
case ("extract", "cargo-full"):
|
||||||
ship.DefaultBehavior.Phase = "travel-to-station";
|
ship.DefaultBehavior.Phase = "travel-to-station";
|
||||||
@@ -728,14 +1009,14 @@ public sealed class SimulationEngine
|
|||||||
|
|
||||||
private static void TrackHistory(ShipRuntime ship)
|
private static void TrackHistory(ShipRuntime ship)
|
||||||
{
|
{
|
||||||
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{ship.Cargo:0.0}";
|
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}";
|
||||||
if (signature == ship.LastSignature)
|
if (signature == ship.LastSignature)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ship.LastSignature = signature;
|
ship.LastSignature = signature;
|
||||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={ship.Cargo:0.#}");
|
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}");
|
||||||
if (ship.History.Count > 18)
|
if (ship.History.Count > 18)
|
||||||
{
|
{
|
||||||
ship.History.RemoveAt(0);
|
ship.History.RemoveAt(0);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
|||||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||||
import type {
|
import type {
|
||||||
FactionSnapshot,
|
FactionSnapshot,
|
||||||
|
InventoryEntry,
|
||||||
PlanetSnapshot,
|
PlanetSnapshot,
|
||||||
ResourceNodeDelta,
|
ResourceNodeDelta,
|
||||||
ResourceNodeSnapshot,
|
ResourceNodeSnapshot,
|
||||||
@@ -149,6 +150,16 @@ interface SystemSummaryVisual {
|
|||||||
anchor: THREE.Vector3;
|
anchor: THREE.Vector3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HistoryWindowState {
|
||||||
|
id: string;
|
||||||
|
target: Selectable;
|
||||||
|
root: HTMLElement;
|
||||||
|
titleEl: HTMLHeadingElement;
|
||||||
|
bodyEl: HTMLDivElement;
|
||||||
|
copyButtonEl: HTMLButtonElement;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
||||||
local: 900,
|
local: 900,
|
||||||
system: 3200,
|
system: 3200,
|
||||||
@@ -204,6 +215,7 @@ export class GameViewer {
|
|||||||
private readonly networkPanelEl: HTMLDivElement;
|
private readonly networkPanelEl: HTMLDivElement;
|
||||||
private readonly performancePanelEl: HTMLDivElement;
|
private readonly performancePanelEl: HTMLDivElement;
|
||||||
private readonly errorEl: HTMLDivElement;
|
private readonly errorEl: HTMLDivElement;
|
||||||
|
private readonly historyLayerEl: HTMLDivElement;
|
||||||
private readonly marqueeEl: HTMLDivElement;
|
private readonly marqueeEl: HTMLDivElement;
|
||||||
private readonly hoverLabelEl: HTMLDivElement;
|
private readonly hoverLabelEl: HTMLDivElement;
|
||||||
|
|
||||||
@@ -241,6 +253,12 @@ export class GameViewer {
|
|||||||
private suppressClickSelection = false;
|
private suppressClickSelection = false;
|
||||||
private activeSystemId?: string;
|
private activeSystemId?: string;
|
||||||
private followedShipId?: string;
|
private followedShipId?: string;
|
||||||
|
private readonly historyWindows: HistoryWindowState[] = [];
|
||||||
|
private historyWindowCounter = 0;
|
||||||
|
private historyWindowZCounter = 10;
|
||||||
|
private historyWindowDragId?: string;
|
||||||
|
private historyWindowDragPointerId?: number;
|
||||||
|
private historyWindowDragOffset = new THREE.Vector2();
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
constructor(container: HTMLElement) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
@@ -286,7 +304,8 @@ export class GameViewer {
|
|||||||
</aside>
|
</aside>
|
||||||
<div class="error-strip" hidden></div>
|
<div class="error-strip" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
<section class="faction-strip"></section>
|
<div class="history-layer"></div>
|
||||||
|
<section class="ship-strip"></section>
|
||||||
<div class="marquee-box"></div>
|
<div class="marquee-box"></div>
|
||||||
<div class="hover-label" hidden></div>
|
<div class="hover-label" hidden></div>
|
||||||
`;
|
`;
|
||||||
@@ -297,10 +316,11 @@ export class GameViewer {
|
|||||||
this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement;
|
this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement;
|
||||||
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
||||||
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
||||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
this.factionStripEl = hud.querySelector(".ship-strip") as HTMLDivElement;
|
||||||
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
this.networkPanelEl = hud.querySelector(".network-body") as HTMLDivElement;
|
||||||
this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement;
|
this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement;
|
||||||
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
||||||
|
this.historyLayerEl = hud.querySelector(".history-layer") as HTMLDivElement;
|
||||||
this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement;
|
this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement;
|
||||||
this.hoverLabelEl = hud.querySelector(".hover-label") as HTMLDivElement;
|
this.hoverLabelEl = hud.querySelector(".hover-label") as HTMLDivElement;
|
||||||
|
|
||||||
@@ -313,6 +333,11 @@ export class GameViewer {
|
|||||||
this.renderer.domElement.addEventListener("click", this.onClick);
|
this.renderer.domElement.addEventListener("click", this.onClick);
|
||||||
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
||||||
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
||||||
|
this.factionStripEl.addEventListener("click", this.onShipStripClick);
|
||||||
|
this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick);
|
||||||
|
this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown);
|
||||||
|
window.addEventListener("pointermove", this.onHistoryWindowPointerMove);
|
||||||
|
window.addEventListener("pointerup", this.onHistoryWindowPointerUp);
|
||||||
window.addEventListener("keydown", this.onKeyDown);
|
window.addEventListener("keydown", this.onKeyDown);
|
||||||
window.addEventListener("keyup", this.onKeyUp);
|
window.addEventListener("keyup", this.onKeyUp);
|
||||||
window.addEventListener("resize", this.onResize);
|
window.addEventListener("resize", this.onResize);
|
||||||
@@ -671,17 +696,34 @@ export class GameViewer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildFactions(factions: FactionSnapshot[]) {
|
private rebuildFactions(_factions: FactionSnapshot[]) {
|
||||||
this.factionStripEl.innerHTML = factions
|
if (!this.world) {
|
||||||
.map((faction) => `
|
this.factionStripEl.innerHTML = "";
|
||||||
<article class="faction-card">
|
return;
|
||||||
<div class="swatch" style="background:${faction.color}"></div>
|
}
|
||||||
<div>
|
|
||||||
<h3>${faction.label}</h3>
|
const ships = [...this.world.ships.values()]
|
||||||
<p>Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}</p>
|
.sort((left, right) => left.label.localeCompare(right.label));
|
||||||
</div>
|
|
||||||
</article>
|
this.factionStripEl.innerHTML = ships
|
||||||
`)
|
.map((ship) => {
|
||||||
|
const fuel = this.inventoryAmount(ship.inventory, "gas");
|
||||||
|
const isSelected = this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship" && this.selectedItems[0].id === ship.id;
|
||||||
|
const isFollowed = this.followedShipId === ship.id;
|
||||||
|
return `
|
||||||
|
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
|
||||||
|
<div class="ship-card-header">
|
||||||
|
<h3>${ship.label}</h3>
|
||||||
|
<span class="ship-card-badge">${ship.shipClass}</span>
|
||||||
|
</div>
|
||||||
|
<p>${ship.systemId}</p>
|
||||||
|
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
|
||||||
|
<p>State ${ship.state}</p>
|
||||||
|
<p>Order ${ship.orderKind ?? "none"}</p>
|
||||||
|
<button type="button" class="ship-card-history-button" data-history-ship-id="${ship.id}">Open History</button>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
})
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -690,6 +732,7 @@ export class GameViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.refreshHistoryWindows();
|
||||||
this.updateSystemPanel();
|
this.updateSystemPanel();
|
||||||
|
|
||||||
if (this.selectedItems.length === 0) {
|
if (this.selectedItems.length === 0) {
|
||||||
@@ -722,14 +765,16 @@ export class GameViewer {
|
|||||||
}
|
}
|
||||||
const parent = this.describeSelectionParent(selected);
|
const parent = this.describeSelectionParent(selected);
|
||||||
this.detailTitleEl.textContent = ship.label;
|
this.detailTitleEl.textContent = ship.label;
|
||||||
|
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
|
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
|
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
|
||||||
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
|
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
|
||||||
|
<p>Inventory ${this.formatInventory(ship.inventory)}</p>
|
||||||
<p>Velocity ${this.formatVector(ship.localVelocity)}</p>
|
<p>Velocity ${this.formatVector(ship.localVelocity)}</p>
|
||||||
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p>
|
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p>
|
||||||
<p class="history">${ship.history.join("<br>")}</p>
|
<p>History available from the ship card list.</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -744,8 +789,9 @@ export class GameViewer {
|
|||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
<p>${station.category} · ${station.systemId}</p>
|
<p>${station.category} · ${station.systemId}</p>
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
|
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips}</p>
|
||||||
<p class="history">${this.renderRecentEvents("station", station.id)}</p>
|
<p>Inventory ${this.formatInventory(station.inventory)}</p>
|
||||||
|
<p>History available in the separate history window.</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -795,6 +841,20 @@ export class GameViewer {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatInventory(entries: InventoryEntry[]): string {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return "empty";
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map((entry) => `${entry.itemId} ${entry.amount.toFixed(0)}`)
|
||||||
|
.join("<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private inventoryAmount(entries: InventoryEntry[], itemId: string): number {
|
||||||
|
return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
const frameStartedAtMs = performance.now();
|
const frameStartedAtMs = performance.now();
|
||||||
const delta = Math.min(this.clock.getDelta(), 0.033);
|
const delta = Math.min(this.clock.getDelta(), 0.033);
|
||||||
@@ -1925,6 +1985,269 @@ export class GameViewer {
|
|||||||
this.updatePanels();
|
this.updatePanels();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onShipStripClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
|
||||||
|
const historyShipId = historyButton?.dataset.historyShipId;
|
||||||
|
if (historyShipId) {
|
||||||
|
this.openHistoryWindow({ kind: "ship", id: historyShipId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = target.closest<HTMLElement>("[data-ship-id]");
|
||||||
|
const shipId = card?.dataset.shipId;
|
||||||
|
if (!shipId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedItems = [{ kind: "ship", id: shipId }];
|
||||||
|
this.syncFollowStateFromSelection();
|
||||||
|
this.updatePanels();
|
||||||
|
};
|
||||||
|
|
||||||
|
private openHistoryWindow(target: Selectable) {
|
||||||
|
const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target));
|
||||||
|
if (existing) {
|
||||||
|
this.bringHistoryWindowToFront(existing);
|
||||||
|
this.refreshHistoryWindows();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `history-${++this.historyWindowCounter}`;
|
||||||
|
const root = document.createElement("aside");
|
||||||
|
root.className = "history-window";
|
||||||
|
root.dataset.historyWindowId = id;
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="history-window-header">
|
||||||
|
<h2 class="history-window-title">History</h2>
|
||||||
|
<div class="history-window-actions">
|
||||||
|
<button type="button" class="history-window-copy">Copy</button>
|
||||||
|
<button type="button" class="history-window-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="history-window-body">No history selected.</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
|
||||||
|
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
|
||||||
|
root.style.left = `${Math.max(20, 20 + ((this.historyWindows.length * 28) % Math.max(40, window.innerWidth - 580)))}px`;
|
||||||
|
root.style.top = `${Math.max(20, 20 + ((this.historyWindows.length * 28) % Math.max(40, window.innerHeight - 420)))}px`;
|
||||||
|
|
||||||
|
const windowState: HistoryWindowState = {
|
||||||
|
id,
|
||||||
|
target,
|
||||||
|
root,
|
||||||
|
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
|
||||||
|
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
|
||||||
|
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
|
||||||
|
text: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
this.historyWindows.push(windowState);
|
||||||
|
this.historyLayerEl.append(root);
|
||||||
|
this.bringHistoryWindowToFront(windowState);
|
||||||
|
this.refreshHistoryWindows();
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshHistoryWindows() {
|
||||||
|
if (!this.world) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const windowState of [...this.historyWindows]) {
|
||||||
|
if (windowState.target.kind === "ship") {
|
||||||
|
const ship = this.world.ships.get(windowState.target.id);
|
||||||
|
if (!ship) {
|
||||||
|
this.destroyHistoryWindow(windowState.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowState.titleEl.textContent = `${ship.label} History`;
|
||||||
|
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
|
||||||
|
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (windowState.target.kind === "station") {
|
||||||
|
const station = this.world.stations.get(windowState.target.id);
|
||||||
|
if (!station) {
|
||||||
|
this.destroyHistoryWindow(windowState.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
windowState.titleEl.textContent = `${station.label} History`;
|
||||||
|
windowState.text = this.renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
|
||||||
|
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.destroyHistoryWindow(windowState.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private destroyHistoryWindow(id: string) {
|
||||||
|
const index = this.historyWindows.findIndex((windowState) => windowState.id === id);
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [removed] = this.historyWindows.splice(index, 1);
|
||||||
|
removed.root.remove();
|
||||||
|
if (this.historyWindowDragId === id) {
|
||||||
|
this.historyWindowDragId = undefined;
|
||||||
|
this.historyWindowDragPointerId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onHistoryLayerClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
|
||||||
|
const windowId = windowEl?.dataset.historyWindowId;
|
||||||
|
if (!windowId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyButton = target.closest(".history-window-copy");
|
||||||
|
if (copyButton) {
|
||||||
|
void this.copyHistoryWindowContent(windowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeButton = target.closest(".history-window-close");
|
||||||
|
if (closeButton) {
|
||||||
|
this.destroyHistoryWindow(windowId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowState = this.historyWindows.find((candidate) => candidate.id === windowId);
|
||||||
|
if (windowState) {
|
||||||
|
this.bringHistoryWindowToFront(windowState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHistoryLayerPointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
|
||||||
|
const windowId = windowEl?.dataset.historyWindowId;
|
||||||
|
if (!windowEl || !windowId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowState = this.historyWindows.find((candidate) => candidate.id === windowId);
|
||||||
|
if (!windowState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.bringHistoryWindowToFront(windowState);
|
||||||
|
if (!target.closest(".history-window-header") || target.closest("button")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = windowState.root.getBoundingClientRect();
|
||||||
|
this.historyWindowDragId = windowId;
|
||||||
|
this.historyWindowDragPointerId = event.pointerId;
|
||||||
|
this.historyWindowDragOffset.set(event.clientX - bounds.left, event.clientY - bounds.top);
|
||||||
|
windowState.root.setPointerCapture?.(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHistoryWindowPointerMove = (event: PointerEvent) => {
|
||||||
|
if (this.historyWindowDragPointerId !== event.pointerId || !this.historyWindowDragId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowState = this.historyWindows.find((candidate) => candidate.id === this.historyWindowDragId);
|
||||||
|
if (!windowState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = windowState.root.offsetWidth;
|
||||||
|
const height = windowState.root.offsetHeight;
|
||||||
|
const left = THREE.MathUtils.clamp(event.clientX - this.historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
|
||||||
|
const top = THREE.MathUtils.clamp(event.clientY - this.historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
|
||||||
|
|
||||||
|
windowState.root.style.left = `${left}px`;
|
||||||
|
windowState.root.style.top = `${top}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
private onHistoryWindowPointerUp = (event: PointerEvent) => {
|
||||||
|
if (this.historyWindowDragPointerId !== event.pointerId || !this.historyWindowDragId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowState = this.historyWindows.find((candidate) => candidate.id === this.historyWindowDragId);
|
||||||
|
this.historyWindowDragPointerId = undefined;
|
||||||
|
this.historyWindowDragId = undefined;
|
||||||
|
windowState?.root.releasePointerCapture?.(event.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
private async copyHistoryWindowContent(windowId: string) {
|
||||||
|
const windowState = this.historyWindows.find((candidate) => candidate.id === windowId);
|
||||||
|
if (!windowState?.text) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.copyTextToClipboard(windowState.text);
|
||||||
|
windowState.copyButtonEl.textContent = "Copied";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
windowState.copyButtonEl.textContent = "Copy";
|
||||||
|
}, 1200);
|
||||||
|
} catch {
|
||||||
|
windowState.copyButtonEl.textContent = "Failed";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
windowState.copyButtonEl.textContent = "Copy";
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async copyTextToClipboard(text: string) {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute("readonly", "true");
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.top = "0";
|
||||||
|
textarea.style.left = "0";
|
||||||
|
textarea.style.width = "1px";
|
||||||
|
textarea.style.height = "1px";
|
||||||
|
textarea.style.opacity = "0";
|
||||||
|
document.body.append(textarea);
|
||||||
|
textarea.focus();
|
||||||
|
textarea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
if (!copied) {
|
||||||
|
throw new Error("execCommand copy failed");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
textarea.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bringHistoryWindowToFront(windowState: HistoryWindowState) {
|
||||||
|
windowState.root.style.zIndex = `${++this.historyWindowZCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
private updateHoverLabel(event: PointerEvent) {
|
private updateHoverLabel(event: PointerEvent) {
|
||||||
if (this.dragMode) {
|
if (this.dragMode) {
|
||||||
this.hoverLabelEl.hidden = true;
|
this.hoverLabelEl.hidden = true;
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export interface ResourceNodeSnapshot {
|
|||||||
|
|
||||||
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
|
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
|
||||||
|
|
||||||
|
export interface InventoryEntry {
|
||||||
|
itemId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StationSnapshot {
|
export interface StationSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -85,8 +90,8 @@ export interface StationSnapshot {
|
|||||||
localPosition: Vector3Dto;
|
localPosition: Vector3Dto;
|
||||||
color: string;
|
color: string;
|
||||||
dockedShips: number;
|
dockedShips: number;
|
||||||
oreStored: number;
|
energyStored: number;
|
||||||
refinedStock: number;
|
inventory: InventoryEntry[];
|
||||||
factionId: string;
|
factionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +110,9 @@ export interface ShipSnapshot {
|
|||||||
orderKind: string | null;
|
orderKind: string | null;
|
||||||
defaultBehaviorKind: string;
|
defaultBehaviorKind: string;
|
||||||
controllerTaskKind: string;
|
controllerTaskKind: string;
|
||||||
cargo: number;
|
|
||||||
cargoCapacity: number;
|
cargoCapacity: number;
|
||||||
cargoItemId: string | null;
|
energyStored: number;
|
||||||
|
inventory: InventoryEntry[];
|
||||||
factionId: string;
|
factionId: string;
|
||||||
health: number;
|
health: number;
|
||||||
history: string[];
|
history: string[];
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ canvas {
|
|||||||
.info-panel,
|
.info-panel,
|
||||||
.network-panel,
|
.network-panel,
|
||||||
.performance-panel,
|
.performance-panel,
|
||||||
.faction-strip {
|
.ship-strip {
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--panel-border);
|
border: 1px solid var(--panel-border);
|
||||||
@@ -112,7 +112,7 @@ canvas {
|
|||||||
.topbar h2,
|
.topbar h2,
|
||||||
.info-panel h2,
|
.info-panel h2,
|
||||||
.info-panel h3,
|
.info-panel h3,
|
||||||
.faction-card h3 {
|
.ship-card h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +214,86 @@ canvas {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.history-window {
|
||||||
|
position: absolute;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
width: min(520px, calc(100vw - 40px));
|
||||||
|
height: min(360px, 56vh);
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 220px;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: auto;
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
background: rgba(6, 12, 24, 0.9);
|
||||||
|
border: 1px solid rgba(127, 214, 255, 0.2);
|
||||||
|
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.42);
|
||||||
|
resize: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(127, 214, 255, 0.12);
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-close,
|
||||||
|
.ship-card-history-button {
|
||||||
|
border: 1px solid rgba(127, 214, 255, 0.22);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 214, 255, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-close {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-copy {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-window-body {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
color: var(--text);
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
user-select: text;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
.error-strip {
|
.error-strip {
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
@@ -238,35 +318,88 @@ canvas {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faction-strip {
|
.history-layer {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-strip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
width: min(920px, calc(100vw - 440px));
|
width: min(920px, calc(100vw - 440px));
|
||||||
min-height: 110px;
|
min-height: 140px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
align-items: stretch;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faction-card {
|
.ship-card {
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||||
background: linear-gradient(180deg, rgba(11, 23, 43, 0.85), rgba(7, 15, 28, 0.9));
|
background: linear-gradient(180deg, rgba(11, 23, 43, 0.85), rgba(7, 15, 28, 0.9));
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 220px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
gap: 8px;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faction-card p {
|
.ship-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(127, 214, 255, 0.38);
|
||||||
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card.is-selected {
|
||||||
|
border-color: rgba(255, 191, 105, 0.82);
|
||||||
|
background: linear-gradient(180deg, rgba(31, 33, 20, 0.9), rgba(20, 18, 10, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card.is-followed {
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(127, 214, 255, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card-badge {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(127, 214, 255, 0.12);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card p {
|
||||||
margin: 6px 0 0;
|
margin: 6px 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
|
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||||
|
font-size: 0.77rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ship-card-history-button {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 8px 12px;
|
||||||
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.swatch {
|
.swatch {
|
||||||
@@ -277,7 +410,7 @@ canvas {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1080px) {
|
@media (max-width: 1080px) {
|
||||||
.faction-strip {
|
.ship-strip {
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
@@ -318,12 +451,19 @@ canvas {
|
|||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.faction-strip {
|
.ship-strip {
|
||||||
left: 20px;
|
left: 20px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 100px;
|
min-height: 126px;
|
||||||
grid-template-columns: 1fr;
|
}
|
||||||
|
|
||||||
|
.history-window {
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: auto;
|
||||||
|
max-width: calc(100vw - 40px);
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"id": "station-core",
|
||||||
|
"label": "Orbital Station",
|
||||||
|
"category": "station",
|
||||||
|
"color": "#8df0d2",
|
||||||
|
"radius": 24,
|
||||||
|
"dockingCapacity": 4,
|
||||||
|
"storage": {
|
||||||
|
"bulk-solid": 2000,
|
||||||
|
"manufactured": 1200,
|
||||||
|
"bulk-liquid": 600,
|
||||||
|
"bulk-gas": 600
|
||||||
|
},
|
||||||
|
"modules": ["docking-clamps", "refinery-stack", "fabricator-array", "power-core", "bulk-bay", "liquid-tank", "gas-tank"]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "trade-hub",
|
"id": "trade-hub",
|
||||||
"label": "Trade Hub",
|
"label": "Trade Hub",
|
||||||
@@ -12,12 +27,12 @@
|
|||||||
{
|
{
|
||||||
"id": "refinery",
|
"id": "refinery",
|
||||||
"label": "Refining Station",
|
"label": "Refining Station",
|
||||||
"category": "refining",
|
"category": "station",
|
||||||
"color": "#ffb86c",
|
"color": "#ffb86c",
|
||||||
"radius": 24,
|
"radius": 24,
|
||||||
"dockingCapacity": 3,
|
"dockingCapacity": 3,
|
||||||
"storage": { "bulk-solid": 2000, "manufactured": 1000 },
|
"storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 },
|
||||||
"modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array"]
|
"modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array", "power-core", "liquid-tank", "gas-tank"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "farm-ring",
|
"id": "farm-ring",
|
||||||
|
|||||||
@@ -11,6 +11,18 @@
|
|||||||
"category": "engine",
|
"category": "engine",
|
||||||
"summary": "Sub-light propulsion package."
|
"summary": "Sub-light propulsion package."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "reactor-core",
|
||||||
|
"label": "Reactor Core",
|
||||||
|
"category": "power",
|
||||||
|
"summary": "Primary onboard generator for ship systems."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "capacitor-bank",
|
||||||
|
"label": "Capacitor Bank",
|
||||||
|
"category": "energy-buffer",
|
||||||
|
"summary": "Transient energy storage for weapons, engines, and industrial tools."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "ftl-core",
|
"id": "ftl-core",
|
||||||
"label": "FTL Core",
|
"label": "FTL Core",
|
||||||
@@ -23,6 +35,18 @@
|
|||||||
"category": "mining",
|
"category": "mining",
|
||||||
"summary": "Excavation laser and ore intake."
|
"summary": "Excavation laser and ore intake."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "mining-turret",
|
||||||
|
"label": "Mining Turret",
|
||||||
|
"category": "mining",
|
||||||
|
"summary": "Articulated mining head for shipborne extraction."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gun-turret",
|
||||||
|
"label": "Gun Turret",
|
||||||
|
"category": "weapon",
|
||||||
|
"summary": "Hull-mounted weapon turret for ship combat."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "bulk-bay",
|
"id": "bulk-bay",
|
||||||
"label": "Bulk Cargo Bay",
|
"label": "Bulk Cargo Bay",
|
||||||
@@ -70,5 +94,23 @@
|
|||||||
"label": "Fabricator Array",
|
"label": "Fabricator Array",
|
||||||
"category": "production",
|
"category": "production",
|
||||||
"summary": "Assembly lines for manufactured goods."
|
"summary": "Assembly lines for manufactured goods."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "power-core",
|
||||||
|
"label": "Power Core",
|
||||||
|
"category": "energy",
|
||||||
|
"summary": "Primary station generator and power distribution."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "liquid-tank",
|
||||||
|
"label": "Liquid Tank",
|
||||||
|
"category": "storage-liquid",
|
||||||
|
"summary": "Tankage for water, coolants, and other liquids."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gas-tank",
|
||||||
|
"label": "Fuel Tank",
|
||||||
|
"category": "storage-gas",
|
||||||
|
"summary": "Pressurized storage for volatile gas and fuel reserves."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
{
|
{
|
||||||
"id": "ore-refining",
|
"id": "ore-refining",
|
||||||
"label": "Ore Refining",
|
"label": "Ore Refining",
|
||||||
"facilityCategory": "refining",
|
"facilityCategory": "station",
|
||||||
"duration": 8,
|
"duration": 8,
|
||||||
"priority": 100,
|
"priority": 100,
|
||||||
|
"requiredModules": ["refinery-stack", "power-core"],
|
||||||
"inputs": [
|
"inputs": [
|
||||||
{ "itemId": "ore", "amount": 60 }
|
{ "itemId": "ore", "amount": 60 }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,41 +1,12 @@
|
|||||||
{
|
{
|
||||||
"initialStations": [
|
"initialStations": [
|
||||||
{ "constructibleId": "trade-hub", "systemId": "helios", "planetIndex": 1, "lagrangeSide": 1 },
|
{ "constructibleId": "station-core", "systemId": "helios", "planetIndex": 2, "lagrangeSide": -1 }
|
||||||
{ "constructibleId": "refinery", "systemId": "helios", "planetIndex": 2, "lagrangeSide": -1 },
|
|
||||||
{ "constructibleId": "farm-ring", "systemId": "helios", "planetIndex": 1, "lagrangeSide": -1 },
|
|
||||||
{ "constructibleId": "shipyard", "systemId": "helios", "planetIndex": 3, "lagrangeSide": 1 },
|
|
||||||
{ "constructibleId": "defense-grid", "systemId": "helios", "planetIndex": 2, "lagrangeSide": 1 }
|
|
||||||
],
|
],
|
||||||
"shipFormations": [
|
"shipFormations": [
|
||||||
{ "shipId": "carrier", "count": 1, "center": [120, 0, 60], "systemId": "helios" },
|
{ "shipId": "constructor", "count": 1, "center": [45, 0, 20], "systemId": "helios" },
|
||||||
{ "shipId": "frigate", "count": 6, "center": [180, 0, 90], "systemId": "helios" },
|
{ "shipId": "miner", "count": 1, "center": [52, 0, 24], "systemId": "helios" }
|
||||||
{ "shipId": "destroyer", "count": 3, "center": [260, 0, 120], "systemId": "helios" },
|
|
||||||
{ "shipId": "cruiser", "count": 2, "center": [220, 0, 180], "systemId": "helios" },
|
|
||||||
{ "shipId": "hauler", "count": 4, "center": [310, 0, -150], "systemId": "helios" },
|
|
||||||
{ "shipId": "frigate", "count": 4, "center": [4350, 0, 560], "systemId": "perseus" },
|
|
||||||
{ "shipId": "cruiser", "count": 1, "center": [4430, 0, 640], "systemId": "perseus" },
|
|
||||||
{ "shipId": "miner", "count": 6, "center": [4620, 0, 700], "systemId": "perseus" }
|
|
||||||
],
|
|
||||||
"patrolRoutes": [
|
|
||||||
{
|
|
||||||
"systemId": "helios",
|
|
||||||
"points": [
|
|
||||||
[180, 0, 120],
|
|
||||||
[360, 0, -140],
|
|
||||||
[620, 0, 210],
|
|
||||||
[260, 0, 320]
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"systemId": "perseus",
|
|
||||||
"points": [
|
|
||||||
[4580, 0, 740],
|
|
||||||
[4750, 0, 480],
|
|
||||||
[5020, 0, 860],
|
|
||||||
[4680, 0, 980]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
|
"patrolRoutes": [],
|
||||||
"miningDefaults": {
|
"miningDefaults": {
|
||||||
"nodeSystemId": "perseus",
|
"nodeSystemId": "perseus",
|
||||||
"refinerySystemId": "helios"
|
"refinerySystemId": "helios"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"hullColor": "#1f4f78",
|
"hullColor": "#1f4f78",
|
||||||
"size": 4,
|
"size": 4,
|
||||||
"maxHealth": 100,
|
"maxHealth": 100,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid"]
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "destroyer",
|
"id": "destroyer",
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
"hullColor": "#6a2e26",
|
"hullColor": "#6a2e26",
|
||||||
"size": 7,
|
"size": 7,
|
||||||
"maxHealth": 240,
|
"maxHealth": 240,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid"]
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret", "gun-turret"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "cruiser",
|
"id": "cruiser",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"hullColor": "#314562",
|
"hullColor": "#314562",
|
||||||
"size": 10,
|
"size": 10,
|
||||||
"maxHealth": 340,
|
"maxHealth": 340,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "turret-grid", "turret-grid", "docking-clamps"]
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gun-turret", "gun-turret", "docking-clamps"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "carrier",
|
"id": "carrier",
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
"hullColor": "#35586d",
|
"hullColor": "#35586d",
|
||||||
"size": 16,
|
"size": 16,
|
||||||
"maxHealth": 900,
|
"maxHealth": 900,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "carrier-bay", "carrier-bay", "turret-grid", "habitat-ring"],
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "carrier-bay", "carrier-bay", "gun-turret", "habitat-ring"],
|
||||||
"dockingCapacity": 6,
|
"dockingCapacity": 6,
|
||||||
"dockingClasses": ["frigate", "destroyer", "cruiser"]
|
"dockingClasses": ["frigate", "destroyer", "cruiser"]
|
||||||
},
|
},
|
||||||
@@ -76,7 +76,24 @@
|
|||||||
"hullColor": "#365f2a",
|
"hullColor": "#365f2a",
|
||||||
"size": 8,
|
"size": 8,
|
||||||
"maxHealth": 180,
|
"maxHealth": 180,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "container-bay", "docking-clamps"]
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "container-bay", "docking-clamps"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "constructor",
|
||||||
|
"label": "Pioneer Constructor",
|
||||||
|
"role": "construction",
|
||||||
|
"shipClass": "industrial",
|
||||||
|
"speed": 20,
|
||||||
|
"ftlSpeed": 2200,
|
||||||
|
"spoolTime": 3.5,
|
||||||
|
"cargoCapacity": 160,
|
||||||
|
"cargoKind": "manufactured",
|
||||||
|
"cargoItemId": "drone-parts",
|
||||||
|
"color": "#9af0c1",
|
||||||
|
"hullColor": "#2d5d47",
|
||||||
|
"size": 9,
|
||||||
|
"maxHealth": 220,
|
||||||
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "fabricator-array", "container-bay", "docking-clamps"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "miner",
|
"id": "miner",
|
||||||
@@ -93,6 +110,6 @@
|
|||||||
"hullColor": "#68552b",
|
"hullColor": "#68552b",
|
||||||
"size": 6,
|
"size": 6,
|
||||||
"maxHealth": 150,
|
"maxHealth": 150,
|
||||||
"modules": ["command-bridge", "ion-drive", "ftl-core", "strip-miner", "bulk-bay", "docking-clamps"]
|
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay", "docking-clamps"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user