From 00a008bda56e3189c263794e8df989c0e114d9d3 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Wed, 18 Mar 2026 00:40:44 -0400 Subject: [PATCH] feat: improved ops-strip with faction and stations --- .../Contracts/WorldContracts.Factions.cs | 21 ++- .../Simulation/Model/FactionRuntimeModels.cs | 2 + .../Simulation/ScenarioLoader.Seeding.cs | 39 +---- apps/backend/Simulation/ScenarioLoader.cs | 1 - .../SimulationEngine.CommanderSystem.cs | 6 +- .../SimulationEngine.Replication.cs | 76 +++++++-- .../SimulationEngine.StationController.cs | 23 ++- apps/viewer/src/ViewerAppController.ts | 4 +- apps/viewer/src/contractsFactions.ts | 19 +++ apps/viewer/src/style.css | 26 ++- apps/viewer/src/viewerControllerFactory.ts | 6 +- apps/viewer/src/viewerFactionStrip.ts | 76 --------- apps/viewer/src/viewerHud.ts | 6 +- .../viewer/src/viewerInteractionController.ts | 51 ++++-- apps/viewer/src/viewerOpsStrip.ts | 154 ++++++++++++++++++ apps/viewer/src/viewerWorldLifecycle.ts | 6 +- 16 files changed, 341 insertions(+), 175 deletions(-) delete mode 100644 apps/viewer/src/viewerFactionStrip.ts create mode 100644 apps/viewer/src/viewerOpsStrip.ts diff --git a/apps/backend/Contracts/WorldContracts.Factions.cs b/apps/backend/Contracts/WorldContracts.Factions.cs index 5fb2706..bb0708f 100644 --- a/apps/backend/Contracts/WorldContracts.Factions.cs +++ b/apps/backend/Contracts/WorldContracts.Factions.cs @@ -1,5 +1,18 @@ namespace SpaceGame.Simulation.Api.Contracts; +public sealed record FactionGoapStateSnapshot( + int MilitaryShipCount, + int MinerShipCount, + int TransportShipCount, + int ConstructorShipCount, + int ControlledSystemCount, + int TargetSystemCount, + bool HasShipFactory, + float OreStockpile, + float RefinedMetalsStockpile); + +public sealed record FactionGoapPrioritySnapshot(string GoalName, float Priority); + public sealed record FactionSnapshot( string Id, string Label, @@ -10,7 +23,9 @@ public sealed record FactionSnapshot( float GoodsProduced, int ShipsBuilt, int ShipsLost, - string? DefaultPolicySetId); + string? DefaultPolicySetId, + FactionGoapStateSnapshot? GoapState, + IReadOnlyList? GoapPriorities); public sealed record FactionDelta( string Id, @@ -22,4 +37,6 @@ public sealed record FactionDelta( float GoodsProduced, int ShipsBuilt, int ShipsLost, - string? DefaultPolicySetId); + string? DefaultPolicySetId, + FactionGoapStateSnapshot? GoapState, + IReadOnlyList? GoapPriorities); diff --git a/apps/backend/Simulation/Model/FactionRuntimeModels.cs b/apps/backend/Simulation/Model/FactionRuntimeModels.cs index dcf1b52..81a2037 100644 --- a/apps/backend/Simulation/Model/FactionRuntimeModels.cs +++ b/apps/backend/Simulation/Model/FactionRuntimeModels.cs @@ -36,6 +36,8 @@ public sealed class CommanderRuntime public CommanderTaskRuntime? ActiveTask { get; set; } public HashSet SubordinateCommanderIds { get; } = new(StringComparer.Ordinal); public bool IsAlive { get; set; } = true; + public FactionPlanningState? LastPlanningState { get; set; } + public IReadOnlyList<(string Name, float Priority)>? LastGoalPriorities { get; set; } } public sealed class CommanderBehaviorRuntime diff --git a/apps/backend/Simulation/ScenarioLoader.Seeding.cs b/apps/backend/Simulation/ScenarioLoader.Seeding.cs index f9385b1..1a6c8cf 100644 --- a/apps/backend/Simulation/ScenarioLoader.Seeding.cs +++ b/apps/backend/Simulation/ScenarioLoader.Seeding.cs @@ -21,10 +21,7 @@ public sealed partial class ScenarioLoader factionIds.Add(DefaultFactionId); } - factionIds.Add(UnclaimedFactionId); - return factionIds - .Distinct(StringComparer.Ordinal) .Select(CreateFaction) .ToList(); } @@ -40,13 +37,6 @@ public sealed partial class ScenarioLoader Color = "#7ed4ff", Credits = MinimumFactionCredits, }, - UnclaimedFactionId => new FactionRuntime - { - Id = factionId, - Label = "Unclaimed", - Color = "#7f8794", - Credits = 0f, - }, _ => new FactionRuntime { Id = factionId, @@ -107,26 +97,21 @@ public sealed partial class ScenarioLoader var claims = new List(); foreach (var node in nodes.Where((candidate) => candidate.Kind == SpatialNodeKind.LagrangePoint)) { - var owningFactionId = stationsByAnchorNodeId.TryGetValue(node.Id, out var station) - ? station.FactionId - : UnclaimedFactionId; - var activatesAtUtc = owningFactionId == UnclaimedFactionId - ? nowUtc - : nowUtc.AddSeconds(8); - var state = owningFactionId == UnclaimedFactionId - ? ClaimStateKinds.Active - : ClaimStateKinds.Activating; + if (!stationsByAnchorNodeId.TryGetValue(node.Id, out var station)) + { + continue; + } claims.Add(new ClaimRuntime { Id = $"claim-{node.Id}", - FactionId = owningFactionId, + FactionId = station.FactionId, SystemId = node.SystemId, NodeId = node.Id, BubbleId = node.BubbleId, PlacedAtUtc = nowUtc, - ActivatesAtUtc = activatesAtUtc, - State = state, + ActivatesAtUtc = nowUtc.AddSeconds(8), + State = ClaimStateKinds.Activating, Health = 100f, }); } @@ -248,11 +233,6 @@ public sealed partial class ScenarioLoader var policies = new List(factions.Count); foreach (var faction in factions) { - if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal)) - { - continue; - } - var policyId = $"policy-{faction.Id}"; faction.DefaultPolicySetId = policyId; policies.Add(new PolicySetRuntime @@ -277,11 +257,6 @@ public sealed partial class ScenarioLoader foreach (var faction in factions) { - if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal)) - { - continue; - } - var commander = new CommanderRuntime { Id = $"commander-faction-{faction.Id}", diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index b53c1ab..9f1cdae 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -6,7 +6,6 @@ namespace SpaceGame.Simulation.Api.Simulation; public sealed partial class ScenarioLoader { private const string DefaultFactionId = "sol-dominion"; - private const string UnclaimedFactionId = "unclaimed"; private const int WorldSeed = 1; private const float MinimumFactionCredits = 0f; private const float MinimumRefineryOre = 0f; diff --git a/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs b/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs index f5ad231..2f87318 100644 --- a/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs +++ b/apps/backend/Simulation/SimulationEngine.CommanderSystem.cs @@ -81,7 +81,11 @@ public sealed partial class SimulationEngine var rankedGoals = _factionGoals .Select(g => (goal: g, priority: g.ComputePriority(state, world, commander))) .Where(x => x.priority > 0f) - .OrderByDescending(x => x.priority); + .OrderByDescending(x => x.priority) + .ToList(); + + commander.LastPlanningState = state; + commander.LastGoalPriorities = rankedGoals.Select(x => (x.goal.Name, x.priority)).ToList(); // Execute the first action of each active goal's plan (top 3 to avoid conflicts). foreach (var (goal, _) in rankedGoals.Take(3)) diff --git a/apps/backend/Simulation/SimulationEngine.Replication.cs b/apps/backend/Simulation/SimulationEngine.Replication.cs index 62af945..ccd8dfa 100644 --- a/apps/backend/Simulation/SimulationEngine.Replication.cs +++ b/apps/backend/Simulation/SimulationEngine.Replication.cs @@ -169,7 +169,7 @@ public sealed partial class SimulationEngine ship.History, ship.CurrentAction, ship.SpatialState)).ToList(), - world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot( + world.Factions.Select(faction => ToFactionDelta(faction, FindFactionCommander(world, faction.Id))).Select(faction => new FactionSnapshot( faction.Id, faction.Label, faction.Color, @@ -179,7 +179,9 @@ public sealed partial class SimulationEngine faction.GoodsProduced, faction.ShipsBuilt, faction.ShipsLost, - faction.DefaultPolicySetId)).ToList()); + faction.DefaultPolicySetId, + faction.GoapState, + faction.GoapPriorities)).ToList()); } public void PrimeDeltaBaseline(SimulationWorld world) @@ -231,7 +233,7 @@ public sealed partial class SimulationEngine foreach (var faction in world.Factions) { - faction.LastDeltaSignature = BuildFactionSignature(faction); + faction.LastDeltaSignature = BuildFactionSignature(faction, FindFactionCommander(world, faction.Id)); } } @@ -402,19 +404,25 @@ public sealed partial class SimulationEngine var deltas = new List(); foreach (var faction in world.Factions) { - var signature = BuildFactionSignature(faction); + var commander = FindFactionCommander(world, faction.Id); + var signature = BuildFactionSignature(faction, commander); if (signature == faction.LastDeltaSignature) { continue; } faction.LastDeltaSignature = signature; - deltas.Add(ToFactionDelta(faction)); + deltas.Add(ToFactionDelta(faction, commander)); } return deltas; } + private static CommanderRuntime? FindFactionCommander(SimulationWorld world, string factionId) => + world.Commanders.FirstOrDefault(c => + c.FactionId == factionId && + string.Equals(c.Kind, CommanderKind.Faction, StringComparison.Ordinal)); + private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.AnchorNodeId}|{node.OreRemaining:0.###}"; @@ -508,8 +516,13 @@ public sealed partial class SimulationEngine .OrderBy(entry => entry.Key, StringComparer.Ordinal) .Select(entry => $"{entry.Key}:{entry.Value:0.###}")); - private static string BuildFactionSignature(FactionRuntime faction) => - $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}"; + private static string BuildFactionSignature(FactionRuntime faction, CommanderRuntime? commander) + { + var goapSig = commander?.LastGoalPriorities is { } prios + ? string.Join(",", prios.Select(p => $"{p.Name}:{p.Priority:0.##}")) + : string.Empty; + return $"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}|{goapSig}"; + } private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new( node.Id, @@ -755,17 +768,44 @@ public sealed partial class SimulationEngine .Select(entry => new InventoryEntry(entry.Key, entry.Value)) .ToList(); - private static FactionDelta ToFactionDelta(FactionRuntime faction) => new( - faction.Id, - faction.Label, - faction.Color, - faction.Credits, - faction.PopulationTotal, - faction.OreMined, - faction.GoodsProduced, - faction.ShipsBuilt, - faction.ShipsLost, - faction.DefaultPolicySetId); + private static FactionDelta ToFactionDelta(FactionRuntime faction, CommanderRuntime? commander) + { + FactionGoapStateSnapshot? goapState = null; + IReadOnlyList? goapPriorities = null; + + if (commander?.LastPlanningState is { } ps) + { + goapState = new FactionGoapStateSnapshot( + ps.MilitaryShipCount, + ps.MinerShipCount, + ps.TransportShipCount, + ps.ConstructorShipCount, + ps.ControlledSystemCount, + ps.TargetSystemCount, + ps.HasShipFactory, + ps.OreStockpile, + ps.RefinedMetalsStockpile); + } + + if (commander?.LastGoalPriorities is { } prios) + { + goapPriorities = prios.Select(p => new FactionGoapPrioritySnapshot(p.Name, p.Priority)).ToList(); + } + + return new FactionDelta( + faction.Id, + faction.Label, + faction.Color, + faction.Credits, + faction.PopulationTotal, + faction.OreMined, + faction.GoodsProduced, + faction.ShipsBuilt, + faction.ShipsLost, + faction.DefaultPolicySetId, + goapState, + goapPriorities); + } private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new( state.SpaceLayer, diff --git a/apps/backend/Simulation/SimulationEngine.StationController.cs b/apps/backend/Simulation/SimulationEngine.StationController.cs index 17bade0..4844982 100644 --- a/apps/backend/Simulation/SimulationEngine.StationController.cs +++ b/apps/backend/Simulation/SimulationEngine.StationController.cs @@ -300,27 +300,24 @@ public sealed partial class SimulationEngine private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId) { - return world.Claims - .Where(claim => claim.State != ClaimStateKinds.Destroyed) - .Select(claim => claim.SystemId) - .Distinct(StringComparer.Ordinal) - .Count(systemId => FactionControlsSystem(world, factionId, systemId)); + return world.Systems.Count(system => FactionControlsSystem(world, factionId, system.Definition.Id)); } private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId) { - var buildableLocations = world.Claims - .Where(claim => - claim.SystemId == systemId && - claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active) - .ToList(); - if (buildableLocations.Count == 0) + var totalLagrangePoints = world.SpatialNodes.Count(node => + node.SystemId == systemId && + node.Kind == SpatialNodeKind.LagrangePoint); + if (totalLagrangePoints == 0) { return false; } - var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId); - return ownedLocations > (buildableLocations.Count / 2f); + var ownedLocations = world.Claims.Count(claim => + claim.SystemId == systemId && + claim.FactionId == factionId && + claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active); + return ownedLocations > (totalLagrangePoints / 2f); } private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold); diff --git a/apps/viewer/src/ViewerAppController.ts b/apps/viewer/src/ViewerAppController.ts index 335d1d2..7373407 100644 --- a/apps/viewer/src/ViewerAppController.ts +++ b/apps/viewer/src/ViewerAppController.ts @@ -124,7 +124,7 @@ export class ViewerAppController { private readonly systemBodyEl: HTMLDivElement; private readonly detailTitleEl: HTMLHeadingElement; private readonly detailBodyEl: HTMLDivElement; - private readonly factionStripEl: HTMLDivElement; + private readonly opsStripEl: HTMLDivElement; private readonly networkSectionEl: HTMLDivElement; private readonly networkSummaryEl: HTMLSpanElement; private readonly networkPanelEl: HTMLDivElement; @@ -208,7 +208,7 @@ export class ViewerAppController { this.systemBodyEl = hud.systemBodyEl; this.detailTitleEl = hud.detailTitleEl; this.detailBodyEl = hud.detailBodyEl; - this.factionStripEl = hud.factionStripEl; + this.opsStripEl = hud.opsStripEl; this.networkSummaryEl = hud.networkSummaryEl; this.networkPanelEl = hud.networkPanelEl; this.performanceSectionEl = hud.performanceSectionEl; diff --git a/apps/viewer/src/contractsFactions.ts b/apps/viewer/src/contractsFactions.ts index 332fa12..2f459a1 100644 --- a/apps/viewer/src/contractsFactions.ts +++ b/apps/viewer/src/contractsFactions.ts @@ -1,3 +1,20 @@ +export interface FactionGoapState { + militaryShipCount: number; + minerShipCount: number; + transportShipCount: number; + constructorShipCount: number; + controlledSystemCount: number; + targetSystemCount: number; + hasShipFactory: boolean; + oreStockpile: number; + refinedMetalsStockpile: number; +} + +export interface FactionGoapPriority { + goalName: string; + priority: number; +} + export interface FactionSnapshot { id: string; label: string; @@ -9,6 +26,8 @@ export interface FactionSnapshot { shipsBuilt: number; shipsLost: number; defaultPolicySetId?: string | null; + goapState?: FactionGoapState | null; + goapPriorities?: FactionGoapPriority[] | null; } export interface FactionDelta extends FactionSnapshot {} diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index 4910035..95d32be 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -87,7 +87,7 @@ canvas { .info-panel, .network-panel, .performance-panel, -.ship-strip { +.ops-strip { backdrop-filter: blur(18px); background: var(--panel); border: 1px solid var(--panel-border); @@ -413,7 +413,7 @@ canvas { pointer-events: none; } -.ship-strip { +.ops-strip { position: absolute; left: 0; right: 0; @@ -536,6 +536,24 @@ canvas { line-height: 1; } +.faction-card { + border-top-color: rgba(180, 130, 255, 0.3); + cursor: default; +} + +.faction-card:hover { + transform: none; + border-color: rgba(180, 130, 255, 0.5); +} + +.station-card { + border-top-color: rgba(127, 255, 180, 0.22); +} + +.station-card:hover { + border-color: rgba(127, 255, 180, 0.5); +} + .swatch { width: 14px; height: 48px; @@ -544,7 +562,7 @@ canvas { } @media (max-width: 1080px) { - .ship-strip { + .ops-strip { width: 100vw; } } @@ -584,7 +602,7 @@ canvas { width: auto; } - .ship-strip { + .ops-strip { left: 0; right: 0; bottom: 0; diff --git a/apps/viewer/src/viewerControllerFactory.ts b/apps/viewer/src/viewerControllerFactory.ts index a9547c8..5942255 100644 --- a/apps/viewer/src/viewerControllerFactory.ts +++ b/apps/viewer/src/viewerControllerFactory.ts @@ -136,7 +136,7 @@ export function createViewerControllers(host: any) { getNetworkStats: () => host.networkStats, getSystemSummaryVisuals: () => host.systemSummaryVisuals, errorEl: host.errorEl, - factionStripEl: host.factionStripEl, + opsStripEl: host.opsStripEl, detailTitleEl: host.detailTitleEl, detailBodyEl: host.detailBodyEl, worldLabel: () => host.world?.label ?? "", @@ -267,8 +267,8 @@ export function wireViewerEvents(host: any) { host.renderer.domElement.addEventListener("click", host.interactionController.onClick); host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick); host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false }); - host.factionStripEl.addEventListener("click", host.interactionController.onShipStripClick); - host.factionStripEl.addEventListener("dblclick", host.interactionController.onShipStripDoubleClick); + host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick); + host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick); host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick); host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown); window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove); diff --git a/apps/viewer/src/viewerFactionStrip.ts b/apps/viewer/src/viewerFactionStrip.ts deleted file mode 100644 index c835525..0000000 --- a/apps/viewer/src/viewerFactionStrip.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { inventoryAmount } from "./viewerMath"; -import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection"; -import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes"; - -export function renderFactionStrip( - world: WorldState | undefined, - selectedItems: Selectable[], - cameraMode: CameraMode, - cameraTargetShipId?: string, - zoomLevel?: ZoomLevel, - activeSystemId?: string, -) { - if (!world) { - return ""; - } - - const ships = [...world.ships.values()] - .filter((ship) => { - if (zoomLevel === "universe" || !activeSystemId) { - return true; - } - - return ship.systemId === activeSystemId; - }) - .sort((left, right) => left.label.localeCompare(right.label)); - - return ships - .map((ship) => { - const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0); - const shipLocation = describeShipLocation(world, ship); - const shipState = describeShipState(world, ship); - const shipAction = describeShipCurrentAction(ship); - const isSelected = selectedItems.length === 1 - && selectedItems[0].kind === "ship" - && selectedItems[0].id === ship.id; - const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id; - - return ` -
-
-

${ship.label}

-
- ${ship.class} - -
-
-

${shipLocation.system}${shipLocation.local ? `
${shipLocation.local}` : ""}

-

Cargo ${cargo.toFixed(0)}

-

State ${shipState}

- ${shipAction ? ` -
-
- ${shipAction.label} - ${Math.round(shipAction.progress * 100)}% -
-
-
-
-
- ` : ""} -
- ${ship.commanderObjective ? `

Objective ${describeShipObjective(ship.commanderObjective)}

` : ""} -

Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}

-

Task ${ship.controllerTaskKind}

-
-
- `; - }) - .join(""); -} diff --git a/apps/viewer/src/viewerHud.ts b/apps/viewer/src/viewerHud.ts index ed1b5e4..cc70427 100644 --- a/apps/viewer/src/viewerHud.ts +++ b/apps/viewer/src/viewerHud.ts @@ -9,7 +9,7 @@ export interface ViewerHudElements { systemBodyEl: HTMLDivElement; detailTitleEl: HTMLHeadingElement; detailBodyEl: HTMLDivElement; - factionStripEl: HTMLDivElement; + opsStripEl: HTMLDivElement; networkSummaryEl: HTMLSpanElement; networkPanelEl: HTMLDivElement; performanceSectionEl: HTMLDivElement; @@ -71,7 +71,7 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
-
+
`; @@ -87,7 +87,7 @@ export function createViewerHud(documentRef: Document): ViewerHudElements { systemBodyEl: root.querySelector(".system-body") as HTMLDivElement, detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement, detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement, - factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement, + opsStripEl: root.querySelector(".ops-strip") as HTMLDivElement, networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement, networkPanelEl: root.querySelector(".network-body") as HTMLDivElement, performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement, diff --git a/apps/viewer/src/viewerInteractionController.ts b/apps/viewer/src/viewerInteractionController.ts index 6d87631..1c0bfbf 100644 --- a/apps/viewer/src/viewerInteractionController.ts +++ b/apps/viewer/src/viewerInteractionController.ts @@ -150,7 +150,7 @@ export class ViewerInteractionController { this.context.updatePanels(); }; - readonly onShipStripClick = (event: MouseEvent) => { + readonly onOpsStripClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; @@ -163,18 +163,25 @@ export class ViewerInteractionController { return; } - const card = target.closest("[data-ship-id]"); - const shipId = card?.dataset.shipId; - if (!shipId) { + const shipCard = target.closest("[data-ship-id]"); + const shipId = shipCard?.dataset.shipId; + if (shipId) { + this.context.setSelectedItems([{ kind: "ship", id: shipId }]); + this.context.syncFollowStateFromSelection(); + this.context.updatePanels(); return; } - this.context.setSelectedItems([{ kind: "ship", id: shipId }]); - this.context.syncFollowStateFromSelection(); - this.context.updatePanels(); + const stationCard = target.closest("[data-station-id]"); + const stationId = stationCard?.dataset.stationId; + if (stationId) { + this.context.setSelectedItems([{ kind: "station", id: stationId }]); + this.context.syncFollowStateFromSelection(); + this.context.updatePanels(); + } }; - readonly onShipStripDoubleClick = (event: MouseEvent) => { + readonly onOpsStripDoubleClick = (event: MouseEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) { return; @@ -184,18 +191,28 @@ export class ViewerInteractionController { return; } - const card = target.closest("[data-ship-id]"); - const shipId = card?.dataset.shipId; - if (!shipId) { + const shipCard = target.closest("[data-ship-id]"); + const shipId = shipCard?.dataset.shipId; + if (shipId) { + this.context.setSelectedItems([{ kind: "ship", id: shipId }]); + this.context.syncFollowStateFromSelection(); + this.context.focusOnSelection({ kind: "ship", id: shipId }); + this.toggleCameraMode("follow"); + this.context.updatePanels(); + this.context.updateGamePanel("Live"); return; } - this.context.setSelectedItems([{ kind: "ship", id: shipId }]); - this.context.syncFollowStateFromSelection(); - this.context.focusOnSelection({ kind: "ship", id: shipId }); - this.toggleCameraMode("follow"); - this.context.updatePanels(); - this.context.updateGamePanel("Live"); + const stationCard = target.closest("[data-station-id]"); + const stationId = stationCard?.dataset.stationId; + if (stationId) { + this.context.setSelectedItems([{ kind: "station", id: stationId }]); + this.context.syncFollowStateFromSelection(); + this.toggleCameraMode("tactical"); + this.context.focusOnSelection({ kind: "station", id: stationId }); + this.context.updatePanels(); + this.context.updateGamePanel("Live"); + } }; readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event); diff --git a/apps/viewer/src/viewerOpsStrip.ts b/apps/viewer/src/viewerOpsStrip.ts new file mode 100644 index 0000000..0b086d5 --- /dev/null +++ b/apps/viewer/src/viewerOpsStrip.ts @@ -0,0 +1,154 @@ +import type { StationSnapshot } from "./contractsInfrastructure"; +import type { FactionSnapshot } from "./contractsFactions"; +import { inventoryAmount } from "./viewerMath"; +import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection"; +import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes"; + +function renderFactionCard(faction: FactionSnapshot): string { + const state = faction.goapState; + const priorities = faction.goapPriorities; + + return ` +
+
+

${faction.label}

+ faction +
+ ${state ? ` +
+

GOAP State

+

Military ${state.militaryShipCount} · Miners ${state.minerShipCount}

+

Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}

+

Systems ${state.controlledSystemCount} / ${state.targetSystemCount}

+

Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}

+
+ ` : ""} + ${priorities && priorities.length > 0 ? ` +
+

Priorities

+ ${priorities.map(p => `

${p.goalName} ${p.priority.toFixed(0)}

`).join("")} +
+ ` : ""} +
+ `; +} + +function renderStationCard(station: StationSnapshot, isSelected: boolean): string { + const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0); + const processes = station.currentProcesses; + + return ` +
+
+

${station.label}

+ ${station.category} +
+

${station.systemId}

+

Docked ${station.dockedShips} / ${station.dockingPads}

+

Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}

+

Modules ${station.installedModules.length}

+ ${processes.length > 0 ? ` +
+ ${processes.map(p => ` +
+
+ ${p.label} + ${Math.round(p.progress * 100)}% +
+
+
+
+
+ `).join("")} +
+ ` : ""} +
+ `; +} + +export function renderOpsStrip( + world: WorldState | undefined, + selectedItems: Selectable[], + cameraMode: CameraMode, + cameraTargetShipId?: string, + zoomLevel?: ZoomLevel, + activeSystemId?: string, +) { + if (!world) { + return ""; + } + + const isSystemFiltered = zoomLevel !== "universe" && activeSystemId != null; + + const factionCards = [...world.factions.values()] + .sort((a, b) => a.label.localeCompare(b.label)) + .map(renderFactionCard) + .join(""); + + const stationCards = [...world.stations.values()] + .filter((station) => !isSystemFiltered || station.systemId === activeSystemId) + .sort((a, b) => a.label.localeCompare(b.label)) + .map((station) => { + const isSelected = selectedItems.length === 1 + && selectedItems[0].kind === "station" + && selectedItems[0].id === station.id; + return renderStationCard(station, isSelected); + }) + .join(""); + + const ships = [...world.ships.values()] + .filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId) + .sort((a, b) => a.label.localeCompare(b.label)); + + const shipCards = ships + .map((ship) => { + const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0); + const shipLocation = describeShipLocation(world, ship); + const shipState = describeShipState(world, ship); + const shipAction = describeShipCurrentAction(ship); + const isSelected = selectedItems.length === 1 + && selectedItems[0].kind === "ship" + && selectedItems[0].id === ship.id; + const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id; + + return ` +
+
+

${ship.label}

+
+ ${ship.class} + +
+
+

${shipLocation.system}${shipLocation.local ? `
${shipLocation.local}` : ""}

+

Cargo ${cargo.toFixed(0)}

+

State ${shipState}

+ ${shipAction ? ` +
+
+ ${shipAction.label} + ${Math.round(shipAction.progress * 100)}% +
+
+
+
+
+ ` : ""} +
+ ${ship.commanderObjective ? `

Objective ${describeShipObjective(ship.commanderObjective)}

` : ""} +

Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}

+

Task ${ship.controllerTaskKind}

+
+
+ `; + }) + .join(""); + + return factionCards + stationCards + shipCards; +} diff --git a/apps/viewer/src/viewerWorldLifecycle.ts b/apps/viewer/src/viewerWorldLifecycle.ts index 5b3b6fa..92216eb 100644 --- a/apps/viewer/src/viewerWorldLifecycle.ts +++ b/apps/viewer/src/viewerWorldLifecycle.ts @@ -1,5 +1,5 @@ import { fetchWorldSnapshot, openWorldStream } from "./api"; -import { renderFactionStrip } from "./viewerFactionStrip"; +import { renderOpsStrip } from "./viewerOpsStrip"; import { updateDetailPanel } from "./viewerPanels"; import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState"; import type { @@ -49,7 +49,7 @@ export interface ViewerWorldLifecycleContext { getNetworkStats: () => NetworkStats; getSystemSummaryVisuals: () => Map; errorEl: HTMLDivElement; - factionStripEl: HTMLDivElement; + opsStripEl: HTMLDivElement; detailTitleEl: HTMLHeadingElement; detailBodyEl: HTMLDivElement; worldLabel: () => string; @@ -194,7 +194,7 @@ export class ViewerWorldLifecycle { } rebuildFactions(_factions: FactionSnapshot[]) { - this.context.factionStripEl.innerHTML = renderFactionStrip( + this.context.opsStripEl.innerHTML = renderOpsStrip( this.context.getWorld(), this.context.getSelectedItems(), this.context.getCameraMode(),