diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs index 94e516d..100b93b 100644 --- a/apps/backend/Contracts/WorldContracts.cs +++ b/apps/backend/Contracts/WorldContracts.cs @@ -74,6 +74,10 @@ public sealed record ResourceNodeDelta( float MaxOre, string ItemId); +public sealed record InventoryEntry( + string ItemId, + float Amount); + public sealed record StationSnapshot( string Id, string Label, @@ -82,8 +86,8 @@ public sealed record StationSnapshot( Vector3Dto LocalPosition, string Color, int DockedShips, - float OreStored, - float RefinedStock, + float EnergyStored, + IReadOnlyList Inventory, string FactionId); public sealed record StationDelta( @@ -94,8 +98,8 @@ public sealed record StationDelta( Vector3Dto LocalPosition, string Color, int DockedShips, - float OreStored, - float RefinedStock, + float EnergyStored, + IReadOnlyList Inventory, string FactionId); public sealed record ShipSnapshot( @@ -111,9 +115,9 @@ public sealed record ShipSnapshot( string? OrderKind, string DefaultBehaviorKind, string ControllerTaskKind, - float Cargo, float CargoCapacity, - string? CargoItemId, + float EnergyStored, + IReadOnlyList Inventory, string FactionId, float Health, IReadOnlyList History); @@ -131,9 +135,9 @@ public sealed record ShipDelta( string? OrderKind, string DefaultBehaviorKind, string ControllerTaskKind, - float Cargo, float CargoCapacity, - string? CargoItemId, + float EnergyStored, + IReadOnlyList Inventory, string FactionId, float Health, IReadOnlyList History); diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs index 79e31c9..99c707a 100644 --- a/apps/backend/Data/WorldDefinitions.cs +++ b/apps/backend/Data/WorldDefinitions.cs @@ -60,6 +60,14 @@ public sealed class ResourceNodeDefinition 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 required string Label { get; set; } @@ -95,6 +103,7 @@ public sealed class ShipDefinition public required string HullColor { get; set; } public float Size { get; set; } public float MaxHealth { get; set; } + public List Modules { get; set; } = []; } public sealed class ConstructibleDefinition @@ -105,6 +114,8 @@ public sealed class ConstructibleDefinition public required string Color { get; set; } public float Radius { get; set; } public int DockingCapacity { get; set; } + public Dictionary Storage { get; set; } = new(StringComparer.Ordinal); + public List Modules { get; set; } = []; } public sealed class ScenarioDefinition diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs index 4df5334..9bd22c2 100644 --- a/apps/backend/Simulation/RuntimeModels.cs +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -13,6 +13,7 @@ public sealed class SimulationWorld public required List Ships { get; init; } public required List Factions { get; init; } public required Dictionary ShipDefinitions { get; init; } + public required Dictionary ItemDefinitions { get; init; } public int TickIntervalMs { get; init; } = 200; public DateTimeOffset GeneratedAtUtc { get; set; } } @@ -42,8 +43,8 @@ public sealed class StationRuntime public required ConstructibleDefinition Definition { get; init; } public required Vector3 Position { get; init; } public required string FactionId { get; init; } - public float OreStored { get; set; } - public float RefinedStock { get; set; } + public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public float EnergyStored { get; set; } public float ProcessTimer { get; set; } public HashSet DockedShipIds { get; } = []; public string LastDeltaSignature { get; set; } = string.Empty; @@ -63,7 +64,8 @@ public sealed class ShipRuntime public required DefaultBehaviorRuntime DefaultBehavior { get; set; } public required ControllerTaskRuntime ControllerTask { get; set; } public float ActionTimer { get; set; } - public float Cargo { get; set; } + public Dictionary Inventory { get; } = new(StringComparer.Ordinal); + public float EnergyStored { get; set; } public string? DockedStationId { get; set; } public float Health { get; set; } public List History { get; } = []; diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index aaf74c3..aa40eaa 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -8,10 +8,10 @@ public sealed class ScenarioLoader private const string DefaultFactionId = "sol-dominion"; private const int TargetSystemCount = 160; private const int WorldSeed = 1; - private const float MinimumFactionCredits = 240f; - private const float MinimumRefineryOre = 60f; - private const float MinimumRefineryStock = 40f; - private const float MinimumShipyardStock = 180f; + private const float MinimumFactionCredits = 0f; + private const float MinimumRefineryOre = 0f; + private const float MinimumRefineryStock = 0f; + private const float MinimumShipyardStock = 0f; private const float MinimumSystemSeparation = 3200f; private static readonly string[] GeneratedSystemNames = [ @@ -87,10 +87,12 @@ public sealed class ScenarioLoader var scenario = Read("scenario.json"); var ships = Read>("ships.json"); var constructibles = Read>("constructibles.json"); + var items = Read>("items.json"); var balance = Read("balance.json"); var shipDefinitions = ships.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 .Select((definition) => new SystemRuntime { @@ -138,14 +140,18 @@ public sealed class ScenarioLoader Definition = definition, Position = ResolveStationPosition(system, plan, balance), FactionId = plan.FactionId ?? DefaultFactionId, - OreStored = 0f, - RefinedStock = 0f, }); } + foreach (var station in stations) + { + station.Inventory["gas"] = 320f; + } + var refinery = stations.FirstOrDefault((station) => - station.Definition.Category == "refining" && station.SystemId == scenario.MiningDefaults.RefinerySystemId) - ?? stations.FirstOrDefault((station) => station.Definition.Category == "refining"); + HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank") && + station.SystemId == scenario.MiningDefaults.RefinerySystemId) + ?? stations.FirstOrDefault((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank")); var patrolRoutes = scenario.PatrolRoutes.ToDictionary( (route) => route.SystemId, @@ -177,6 +183,13 @@ public sealed class ScenarioLoader ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold }, 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, Factions = factions, ShipDefinitions = shipDefinitions, + ItemDefinitions = itemDefinitions, GeneratedAtUtc = DateTimeOffset.UtcNow, }; } @@ -755,29 +769,32 @@ public sealed class ScenarioLoader .ToList(); var refineries = ownedStations - .Where((station) => station.Definition.Category == "refining") + .Where((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank")) .ToList(); if (refineries.Count > 0) { 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")) { - shipyard.RefinedStock = MathF.Max(shipyard.RefinedStock, MinimumShipyardStock); + shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock); } } } + private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => + inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + private static string ToFactionLabel(string factionId) { return string.Join(" ", @@ -801,7 +818,7 @@ public sealed class ScenarioLoader IReadOnlyDictionary> patrolRoutes, 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 { @@ -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 { @@ -863,5 +880,11 @@ public sealed class ScenarioLoader : 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); } diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index cd5ef3b..c821a77 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -1,13 +1,22 @@ +using SpaceGame.Simulation.Api.Data; using SpaceGame.Simulation.Api.Contracts; namespace SpaceGame.Simulation.Api.Simulation; 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) { var events = new List(); + UpdateStationPower(world, deltaSeconds, events); UpdateStations(world, deltaSeconds, events); foreach (var ship in world.Ships) @@ -17,6 +26,7 @@ public sealed class SimulationEngine var previousBehavior = ship.DefaultBehavior.Kind; var previousTask = ship.ControllerTask.Kind; + UpdateShipPower(ship, world, deltaSeconds, events); RefreshControlLayers(ship); PlanControllerTask(ship, world); var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds); @@ -90,8 +100,8 @@ public sealed class SimulationEngine station.LocalPosition, station.Color, station.DockedShips, - station.OreStored, - station.RefinedStock, + station.EnergyStored, + station.Inventory, station.FactionId)).ToList(), world.Ships.Select(ToShipDelta).Select((ship) => new ShipSnapshot( ship.Id, @@ -106,9 +116,9 @@ public sealed class SimulationEngine ship.OrderKind, ship.DefaultBehaviorKind, ship.ControllerTaskKind, - ship.Cargo, ship.CargoCapacity, - ship.CargoItemId, + ship.EnergyStored, + ship.Inventory, ship.FactionId, ship.Health, ship.History)).ToList(), @@ -222,7 +232,7 @@ public sealed class SimulationEngine $"{node.SystemId}|{node.OreRemaining:0.###}"; 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) => string.Join("|", @@ -240,9 +250,18 @@ public sealed class SimulationEngine ship.Order?.Kind ?? "none", ship.DefaultBehavior.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.###")); + private static string BuildInventorySignature(IReadOnlyDictionary 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) => $"{faction.Credits:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}"; @@ -263,8 +282,8 @@ public sealed class SimulationEngine ToDto(station.Position), station.Definition.Color, station.DockedShipIds.Count, - station.OreStored, - station.RefinedStock, + station.EnergyStored, + ToInventoryEntries(station.Inventory), station.FactionId); private static ShipDelta ToShipDelta(ShipRuntime ship) => new( @@ -280,13 +299,20 @@ public sealed class SimulationEngine ship.Order?.Kind, ship.DefaultBehavior.Kind, ship.ControllerTask.Kind, - ship.Cargo, ship.Definition.CargoCapacity, - ship.Definition.CargoItemId, + ship.EnergyStored, + ToInventoryEntries(ship.Inventory), ship.FactionId, ship.Health, ship.History.ToList()); + private static IReadOnlyList ToInventoryEntries(IReadOnlyDictionary 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( faction.Id, faction.Label, @@ -332,11 +358,17 @@ public sealed class SimulationEngine { foreach (var station in world.Stations) { - if (station.Definition.Category != "refining" || station.OreStored < 60f) + if (!HasRefineryCapability(station.Definition) || GetInventoryAmount(station.Inventory, "ore") < 60f) { continue; } + if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds)) + { + station.ProcessTimer = 0f; + continue; + } + station.ProcessTimer += deltaSeconds; if (station.ProcessTimer < 8f) { @@ -344,8 +376,8 @@ public sealed class SimulationEngine } station.ProcessTimer = 0f; - station.OreStored -= 60f; - station.RefinedStock += 60f; + RemoveInventory(station.Inventory, "ore", 60f); + AddInventory(station.Inventory, "refined-metals", 60f); 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); 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 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 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 modules, string moduleId) => + modules.Count((candidate) => string.Equals(candidate, moduleId, StringComparison.Ordinal)); + + private static float GetInventoryAmount(IReadOnlyDictionary inventory, string itemId) => + inventory.TryGetValue(itemId, out var amount) ? amount : 0f; + + private static void AddInventory(IDictionary inventory, string itemId, float amount) + { + if (amount <= 0f) + { + return; + } + + inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary)inventory, itemId) + amount; + } + + private static float RemoveInventory(IDictionary inventory, string itemId, float amount) + { + var current = GetInventoryAmount((IReadOnlyDictionary)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) { if (ship.Order is not null && ship.Order.Status == "queued") @@ -495,6 +692,7 @@ public sealed class SimulationEngine switch (task.Kind) { case "idle": + TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); ship.State = "idle"; ship.TargetPosition = ship.Position; return "none"; @@ -529,6 +727,7 @@ public sealed class SimulationEngine var distance = ship.Position.DistanceTo(task.TargetPosition.Value); if (distance <= task.Threshold) { + TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds); ship.Position = task.TargetPosition.Value; ship.TargetPosition = ship.Position; ship.SystemId = task.TargetSystemId; @@ -537,15 +736,18 @@ public sealed class SimulationEngine } var speed = ship.Definition.Speed; + var energyCost = world.Balance.Energy.MoveDrain * deltaSeconds; if (ship.SystemId != task.TargetSystemId) { ship.State = distance > 800f ? "ftl" : "spooling-ftl"; speed = ship.Definition.FtlSpeed; + energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else if (distance > 200f) { ship.State = distance > 500f ? "warping" : "spooling-warp"; speed = ship.Definition.Speed * 4.5f; + energyCost = world.Balance.Energy.WarpDrain * deltaSeconds; } else { @@ -553,12 +755,26 @@ public sealed class SimulationEngine 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); return "none"; } 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 node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId); if (node is null || task.TargetPosition is null) @@ -572,11 +788,25 @@ public sealed class SimulationEngine var distance = ship.Position.DistanceTo(task.TargetPosition.Value); 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.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.TargetPosition = ship.Position; + return "none"; + } + ship.State = "mining"; ship.ActionTimer += deltaSeconds; if (ship.ActionTimer < 1f) @@ -585,16 +815,20 @@ public sealed class SimulationEngine } 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); - ship.Cargo += mined; + 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; } - 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) @@ -612,11 +846,32 @@ public sealed class SimulationEngine var distance = ship.Position.DistanceTo(task.TargetPosition.Value); 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.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.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.ActionTimer += deltaSeconds; if (ship.ActionTimer < world.Balance.DockingDuration) @@ -651,11 +906,23 @@ public sealed class SimulationEngine 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.State = "transferring"; - var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds); - ship.Cargo -= moved; - station.OreStored += moved; + 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) + { + RemoveInventory(ship.Inventory, cargoItemId, moved); + AddInventory(station.Inventory, cargoItemId, moved); + } var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId); if (faction is not null) { @@ -663,7 +930,7 @@ public sealed class SimulationEngine 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) @@ -678,6 +945,20 @@ public sealed class SimulationEngine ship.TargetPosition = task.TargetPosition.Value; 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); ship.DockedStationId = null; ship.State = "undocking"; @@ -699,7 +980,7 @@ public sealed class SimulationEngine switch (ship.DefaultBehavior.Phase, controllerEvent) { 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; case ("extract", "cargo-full"): ship.DefaultBehavior.Phase = "travel-to-station"; @@ -728,14 +1009,14 @@ public sealed class SimulationEngine 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) { return; } 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) { ship.History.RemoveAt(0); diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index fc0b134..47b0225 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -2,6 +2,7 @@ import * as THREE from "three"; import { fetchWorldSnapshot, openWorldStream } from "./api"; import type { FactionSnapshot, + InventoryEntry, PlanetSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, @@ -149,6 +150,16 @@ interface SystemSummaryVisual { anchor: THREE.Vector3; } +interface HistoryWindowState { + id: string; + target: Selectable; + root: HTMLElement; + titleEl: HTMLHeadingElement; + bodyEl: HTMLDivElement; + copyButtonEl: HTMLButtonElement; + text: string; +} + const ZOOM_DISTANCE: Record = { local: 900, system: 3200, @@ -204,6 +215,7 @@ export class GameViewer { private readonly networkPanelEl: HTMLDivElement; private readonly performancePanelEl: HTMLDivElement; private readonly errorEl: HTMLDivElement; + private readonly historyLayerEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement; private readonly hoverLabelEl: HTMLDivElement; @@ -241,6 +253,12 @@ export class GameViewer { private suppressClickSelection = false; private activeSystemId?: 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) { this.container = container; @@ -286,7 +304,8 @@ export class GameViewer { -
+
+
`; @@ -297,10 +316,11 @@ export class GameViewer { this.systemBodyEl = hud.querySelector(".system-body") as HTMLDivElement; this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement; 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.performancePanelEl = hud.querySelector(".performance-body") 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.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("dblclick", this.onDoubleClick); 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("keyup", this.onKeyUp); window.addEventListener("resize", this.onResize); @@ -671,17 +696,34 @@ export class GameViewer { } } - private rebuildFactions(factions: FactionSnapshot[]) { - this.factionStripEl.innerHTML = factions - .map((faction) => ` -
-
-
-

${faction.label}

-

Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}

-
-
- `) + private rebuildFactions(_factions: FactionSnapshot[]) { + if (!this.world) { + this.factionStripEl.innerHTML = ""; + return; + } + + const ships = [...this.world.ships.values()] + .sort((left, right) => left.label.localeCompare(right.label)); + + 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 ` +
+
+

${ship.label}

+ ${ship.shipClass} +
+

${ship.systemId}

+

Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}

+

State ${ship.state}

+

Order ${ship.orderKind ?? "none"}

+ +
+ `; + }) .join(""); } @@ -690,6 +732,7 @@ export class GameViewer { return; } + this.refreshHistoryWindows(); this.updateSystemPanel(); if (this.selectedItems.length === 0) { @@ -722,14 +765,16 @@ export class GameViewer { } const parent = this.describeSelectionParent(selected); this.detailTitleEl.textContent = ship.label; + const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); this.detailBodyEl.innerHTML = `

${ship.shipClass} · ${ship.role} · ${ship.systemId}

Parent ${parent}

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

-

Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}

+

Energy ${ship.energyStored.toFixed(0)}
Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}

+

Inventory ${this.formatInventory(ship.inventory)}

Velocity ${this.formatVector(ship.localVelocity)}

${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}

-

${ship.history.join("
")}

+

History available from the ship card list.

`; return; } @@ -744,8 +789,9 @@ export class GameViewer { this.detailBodyEl.innerHTML = `

${station.category} · ${station.systemId}

Parent ${parent}

-

Ore ${station.oreStored.toFixed(0)}
Refined ${station.refinedStock.toFixed(0)}
Docked ${station.dockedShips}

-

${this.renderRecentEvents("station", station.id)}

+

Energy ${station.energyStored.toFixed(0)}
Docked ${station.dockedShips}

+

Inventory ${this.formatInventory(station.inventory)}

+

History available in the separate history window.

`; 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("
"); + } + + private inventoryAmount(entries: InventoryEntry[], itemId: string): number { + return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0; + } + private render() { const frameStartedAtMs = performance.now(); const delta = Math.min(this.clock.getDelta(), 0.033); @@ -1925,6 +1985,269 @@ export class GameViewer { this.updatePanels(); }; + private onShipStripClick = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof HTMLElement)) { + return; + } + + const historyButton = target.closest("[data-history-ship-id]"); + const historyShipId = historyButton?.dataset.historyShipId; + if (historyShipId) { + this.openHistoryWindow({ kind: "ship", id: historyShipId }); + return; + } + + const card = target.closest("[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 = ` +
+

History

+
+ + +
+
+
No history selected.
+ `; + + 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", "
"); + 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("
", "\n") || "No history yet."; + windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "
"); + 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("[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("[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) { if (this.dragMode) { this.hoverLabelEl.hidden = true; diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 3e82ea4..2a744ae 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -77,6 +77,11 @@ export interface ResourceNodeSnapshot { export interface ResourceNodeDelta extends ResourceNodeSnapshot {} +export interface InventoryEntry { + itemId: string; + amount: number; +} + export interface StationSnapshot { id: string; label: string; @@ -85,8 +90,8 @@ export interface StationSnapshot { localPosition: Vector3Dto; color: string; dockedShips: number; - oreStored: number; - refinedStock: number; + energyStored: number; + inventory: InventoryEntry[]; factionId: string; } @@ -105,9 +110,9 @@ export interface ShipSnapshot { orderKind: string | null; defaultBehaviorKind: string; controllerTaskKind: string; - cargo: number; cargoCapacity: number; - cargoItemId: string | null; + energyStored: number; + inventory: InventoryEntry[]; factionId: string; health: number; history: string[]; diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index e4ab798..b1c4d14 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -87,7 +87,7 @@ canvas { .info-panel, .network-panel, .performance-panel, -.faction-strip { +.ship-strip { backdrop-filter: blur(18px); background: var(--panel); border: 1px solid var(--panel-border); @@ -112,7 +112,7 @@ canvas { .topbar h2, .info-panel h2, .info-panel h3, -.faction-card h3 { +.ship-card h3 { margin: 0; } @@ -214,6 +214,86 @@ canvas { 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 { border-radius: 14px; padding: 12px 14px; @@ -238,35 +318,88 @@ canvas { display: none; } -.faction-strip { +.history-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.ship-strip { position: absolute; left: 20px; bottom: 20px; width: min(920px, calc(100vw - 440px)); - min-height: 110px; + min-height: 140px; border-radius: 24px; padding: 16px; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + display: flex; + align-items: stretch; gap: 12px; + overflow-x: auto; + overflow-y: hidden; pointer-events: auto; + scrollbar-width: thin; } -.faction-card { +.ship-card { border-radius: 18px; 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)); padding: 14px; + min-width: 220px; + max-width: 220px; display: flex; - gap: 12px; - align-items: flex-start; + flex-direction: column; + gap: 8px; 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; color: var(--muted); 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 { @@ -277,7 +410,7 @@ canvas { } @media (max-width: 1080px) { - .faction-strip { + .ship-strip { right: 20px; width: auto; } @@ -318,12 +451,19 @@ canvas { width: auto; } - .faction-strip { + .ship-strip { left: 20px; right: 20px; bottom: 20px; width: auto; - min-height: 100px; - grid-template-columns: 1fr; + min-height: 126px; + } + + .history-window { + left: 20px; + right: 20px; + width: auto; + max-width: calc(100vw - 40px); + max-height: calc(100vh - 40px); } } diff --git a/shared/data/constructibles.json b/shared/data/constructibles.json index 6d63bf2..9606378 100644 --- a/shared/data/constructibles.json +++ b/shared/data/constructibles.json @@ -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", "label": "Trade Hub", @@ -12,12 +27,12 @@ { "id": "refinery", "label": "Refining Station", - "category": "refining", + "category": "station", "color": "#ffb86c", "radius": 24, "dockingCapacity": 3, - "storage": { "bulk-solid": 2000, "manufactured": 1000 }, - "modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array"] + "storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 }, + "modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array", "power-core", "liquid-tank", "gas-tank"] }, { "id": "farm-ring", diff --git a/shared/data/modules.json b/shared/data/modules.json index 0ae507d..42078b8 100644 --- a/shared/data/modules.json +++ b/shared/data/modules.json @@ -11,6 +11,18 @@ "category": "engine", "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", "label": "FTL Core", @@ -23,6 +35,18 @@ "category": "mining", "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", "label": "Bulk Cargo Bay", @@ -70,5 +94,23 @@ "label": "Fabricator Array", "category": "production", "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." } ] diff --git a/shared/data/recipes.json b/shared/data/recipes.json index 26f643f..b0c904e 100644 --- a/shared/data/recipes.json +++ b/shared/data/recipes.json @@ -2,9 +2,10 @@ { "id": "ore-refining", "label": "Ore Refining", - "facilityCategory": "refining", + "facilityCategory": "station", "duration": 8, "priority": 100, + "requiredModules": ["refinery-stack", "power-core"], "inputs": [ { "itemId": "ore", "amount": 60 } ], diff --git a/shared/data/scenario.json b/shared/data/scenario.json index 3d56114..cf19edc 100644 --- a/shared/data/scenario.json +++ b/shared/data/scenario.json @@ -1,41 +1,12 @@ { "initialStations": [ - { "constructibleId": "trade-hub", "systemId": "helios", "planetIndex": 1, "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 } + { "constructibleId": "station-core", "systemId": "helios", "planetIndex": 2, "lagrangeSide": -1 } ], "shipFormations": [ - { "shipId": "carrier", "count": 1, "center": [120, 0, 60], "systemId": "helios" }, - { "shipId": "frigate", "count": 6, "center": [180, 0, 90], "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] - ] - } + { "shipId": "constructor", "count": 1, "center": [45, 0, 20], "systemId": "helios" }, + { "shipId": "miner", "count": 1, "center": [52, 0, 24], "systemId": "helios" } ], + "patrolRoutes": [], "miningDefaults": { "nodeSystemId": "perseus", "refinerySystemId": "helios" diff --git a/shared/data/ships.json b/shared/data/ships.json index 4084d05..2bef949 100644 --- a/shared/data/ships.json +++ b/shared/data/ships.json @@ -12,7 +12,7 @@ "hullColor": "#1f4f78", "size": 4, "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", @@ -27,7 +27,7 @@ "hullColor": "#6a2e26", "size": 7, "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", @@ -42,7 +42,7 @@ "hullColor": "#314562", "size": 10, "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", @@ -57,7 +57,7 @@ "hullColor": "#35586d", "size": 16, "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, "dockingClasses": ["frigate", "destroyer", "cruiser"] }, @@ -76,7 +76,24 @@ "hullColor": "#365f2a", "size": 8, "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", @@ -93,6 +110,6 @@ "hullColor": "#68552b", "size": 6, "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"] } ]