feat(viewer): add GM Ops Console window replacing ops strip

Introduces a floating, draggable, resizable Game Master console as the
first of a planned series of GM/debug windows. Replaces the horizontal
ops-strip card layout with proper data tables using TanStack Table v8.

- GmWindow.vue: reusable draggable+resizable floating window base;
  snapshots offsetWidth/Height on drag start so resize is preserved
- GmOpsWindow.vue: Ships / Stations / Factions tabs with global filter,
  column sorting, and drag-to-reorder columns (useColumnOrder composable)
- gmStore.ts: Pinia store fed from ViewerWorldLifecycle.rebuildFactions
  with raw world arrays (ships, stations, factions)
- Removes opsStripEl binding (was stored but never read by controller)
- GM Console toggle button replaces the bottom ops strip

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-20 00:24:32 -04:00
parent cd1fe776a5
commit 892d069b92
10 changed files with 977 additions and 14 deletions

View File

@@ -0,0 +1,617 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import {
useVueTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
FlexRender,
type SortingState,
type ColumnOrderState,
type Updater,
} from "@tanstack/vue-table";
import { storeToRefs } from "pinia";
import GmWindow from "./GmWindow.vue";
import { useGmStore } from "../../ui/stores/gmStore";
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
// ── Column ordering composable ─────────────────────────────────────────────
function useColumnOrder(initialIds: string[]) {
const columnOrder = ref<ColumnOrderState>([...initialIds]);
const draggingId = ref<string | null>(null);
const overId = ref<string | null>(null);
function onColumnOrderChange(updater: Updater<ColumnOrderState>) {
columnOrder.value = typeof updater === "function" ? updater(columnOrder.value) : updater;
}
function onDragStart(e: DragEvent, id: string) {
draggingId.value = id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", id);
}
}
function onDragOver(e: DragEvent, id: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
overId.value = id;
}
function onDragLeave() {
overId.value = null;
}
function onDrop(targetId: string) {
if (!draggingId.value || draggingId.value === targetId) return;
const order = [...columnOrder.value];
const from = order.indexOf(draggingId.value);
const to = order.indexOf(targetId);
order.splice(from, 1);
order.splice(to, 0, draggingId.value);
columnOrder.value = order;
draggingId.value = null;
overId.value = null;
}
function onDragEnd() {
draggingId.value = null;
overId.value = null;
}
return { columnOrder, draggingId, overId, onColumnOrderChange, onDragStart, onDragOver, onDragLeave, onDrop, onDragEnd };
}
const emit = defineEmits<{
close: [];
focus: [id: string, kind: "ship" | "station"];
}>();
type TabId = "ships" | "stations" | "factions";
const activeTab = ref<TabId>("ships");
const gmStore = useGmStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
// Faction name lookup
const factionMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.label])),
);
// ── Ships table ────────────────────────────────────────────────────────────
type ShipRow = {
id: string;
label: string;
class: string;
faction: string;
system: string;
state: string;
behavior: string;
task: string;
cargo: number;
health: number;
};
const shipRows = computed<ShipRow[]>(() =>
gmStore.ships.map((s) => ({
id: s.id,
label: s.label,
class: s.class,
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
state: s.state,
behavior: s.defaultBehaviorKind + (s.behaviorPhase ? ` · ${s.behaviorPhase}` : ""),
task: s.controllerTaskKind,
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
health: Math.round(s.health),
})),
);
const shipColumnHelper = createColumnHelper<ShipRow>();
const shipColumns = [
shipColumnHelper.accessor("label", { header: "Name" }),
shipColumnHelper.accessor("class", { header: "Class" }),
shipColumnHelper.accessor("faction", { header: "Faction" }),
shipColumnHelper.accessor("system", { header: "System" }),
shipColumnHelper.accessor("state", { header: "State" }),
shipColumnHelper.accessor("behavior", { header: "Behavior" }),
shipColumnHelper.accessor("task", { header: "Task" }),
shipColumnHelper.accessor("cargo", { header: "Cargo" }),
shipColumnHelper.accessor("health", { header: "HP" }),
];
const shipFilter = ref("");
const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "faction", "system", "state", "behavior", "task", "cargo", "health"]);
const shipTable = useVueTable({
get data() { return shipRows.value; },
columns: shipColumns,
state: {
get globalFilter() { return shipFilter.value; },
get sorting() { return shipSorting.value; },
get columnOrder() { return shipOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { shipFilter.value = String(v); },
onSortingChange: (updater) => {
shipSorting.value = typeof updater === "function" ? updater(shipSorting.value) : updater;
},
onColumnOrderChange: shipOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Stations table ─────────────────────────────────────────────────────────
type StationRow = {
id: string;
label: string;
category: string;
faction: string;
system: string;
docked: string;
cargo: number;
population: number;
modules: number;
};
const stationRows = computed<StationRow[]>(() =>
gmStore.stations.map((s) => ({
id: s.id,
label: s.label,
category: s.category,
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
docked: `${s.dockedShips} / ${s.dockingPads}`,
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
population: Math.round(s.population),
modules: s.installedModules.length,
})),
);
const stationColumnHelper = createColumnHelper<StationRow>();
const stationColumns = [
stationColumnHelper.accessor("label", { header: "Name" }),
stationColumnHelper.accessor("category", { header: "Category" }),
stationColumnHelper.accessor("faction", { header: "Faction" }),
stationColumnHelper.accessor("system", { header: "System" }),
stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("cargo", { header: "Cargo" }),
stationColumnHelper.accessor("population", { header: "Pop" }),
stationColumnHelper.accessor("modules", { header: "Modules" }),
];
const stationFilter = ref("");
const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "faction", "system", "docked", "cargo", "population", "modules"]);
const stationTable = useVueTable({
get data() { return stationRows.value; },
columns: stationColumns,
state: {
get globalFilter() { return stationFilter.value; },
get sorting() { return stationSorting.value; },
get columnOrder() { return stationOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { stationFilter.value = String(v); },
onSortingChange: (updater) => {
stationSorting.value = typeof updater === "function" ? updater(stationSorting.value) : updater;
},
onColumnOrderChange: stationOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Factions table ─────────────────────────────────────────────────────────
type FactionRow = {
id: string;
label: string;
credits: number;
population: number;
military: number;
miners: number;
transport: number;
constructors: number;
systems: string;
ore: number;
shipsBuilt: number;
shipsLost: number;
};
const factionRows = computed<FactionRow[]>(() =>
gmStore.factions.map((f) => {
const gs = f.goapState;
return {
id: f.id,
label: f.label,
credits: Math.round(f.credits),
population: Math.round(f.populationTotal),
military: gs?.militaryShipCount ?? 0,
miners: gs?.minerShipCount ?? 0,
transport: gs?.transportShipCount ?? 0,
constructors: gs?.constructorShipCount ?? 0,
systems: gs ? `${gs.controlledSystemCount} / ${gs.targetSystemCount}` : "—",
ore: gs ? Math.round(gs.oreStockpile) : 0,
shipsBuilt: f.shipsBuilt,
shipsLost: f.shipsLost,
};
}),
);
const factionColumnHelper = createColumnHelper<FactionRow>();
const factionColumns = [
factionColumnHelper.accessor("label", { header: "Faction" }),
factionColumnHelper.accessor("credits", { header: "Credits" }),
factionColumnHelper.accessor("population", { header: "Pop" }),
factionColumnHelper.accessor("military", { header: "Military" }),
factionColumnHelper.accessor("miners", { header: "Miners" }),
factionColumnHelper.accessor("transport", { header: "Transport" }),
factionColumnHelper.accessor("constructors", { header: "Constructors" }),
factionColumnHelper.accessor("systems", { header: "Systems" }),
factionColumnHelper.accessor("ore", { header: "Ore" }),
factionColumnHelper.accessor("shipsBuilt", { header: "Built" }),
factionColumnHelper.accessor("shipsLost", { header: "Lost" }),
];
const factionFilter = ref("");
const factionSorting = ref<SortingState>([]);
const factionOrder = useColumnOrder(["label", "credits", "population", "military", "miners", "transport", "constructors", "systems", "ore", "shipsBuilt", "shipsLost"]);
const factionTable = useVueTable({
get data() { return factionRows.value; },
columns: factionColumns,
state: {
get globalFilter() { return factionFilter.value; },
get sorting() { return factionSorting.value; },
get columnOrder() { return factionOrder.columnOrder.value; },
},
onGlobalFilterChange: (v) => { factionFilter.value = String(v); },
onSortingChange: (updater) => {
factionSorting.value = typeof updater === "function" ? updater(factionSorting.value) : updater;
},
onColumnOrderChange: factionOrder.onColumnOrderChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
});
// ── Row counts ─────────────────────────────────────────────────────────────
const tabs: { id: TabId; label: string }[] = [
{ id: "ships", label: "Ships" },
{ id: "stations", label: "Stations" },
{ id: "factions", label: "Factions" },
];
const activeFilter = computed({
get: () => {
if (activeTab.value === "ships") return shipFilter.value;
if (activeTab.value === "stations") return stationFilter.value;
return factionFilter.value;
},
set: (v: string) => {
if (activeTab.value === "ships") shipFilter.value = v;
else if (activeTab.value === "stations") stationFilter.value = v;
else factionFilter.value = v;
},
});
const activeRowCount = computed(() => {
if (activeTab.value === "ships") return shipTable.getFilteredRowModel().rows.length;
if (activeTab.value === "stations") return stationTable.getFilteredRowModel().rows.length;
return factionTable.getFilteredRowModel().rows.length;
});
const activeTotalCount = computed(() => {
if (activeTab.value === "ships") return gmStore.ships.length;
if (activeTab.value === "stations") return gmStore.stations.length;
return gmStore.factions.length;
});
// ── Row interaction ────────────────────────────────────────────────────────
function onShipClick(row: ShipRow) {
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
}
function onShipDblClick(row: ShipRow) {
selectionStore.selectSelection({ id: row.id, kind: "ship", label: row.label }, "ui");
emit("focus", row.id, "ship");
}
function onStationClick(row: StationRow) {
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
}
function onStationDblClick(row: StationRow) {
selectionStore.selectSelection({ id: row.id, kind: "station", label: row.label }, "ui");
emit("focus", row.id, "station");
}
function isShipSelected(id: string) {
return selectedEntityKind.value === "ship" && selectedEntityId.value === id;
}
function isStationSelected(id: string) {
return selectedEntityKind.value === "station" && selectedEntityId.value === id;
}
</script>
<template>
<GmWindow
title="Ships"
:initial-width="980"
:initial-height="560"
:initial-x="80"
:initial-y="80"
@close="emit('close')"
>
<div class="flex h-full flex-col">
<!-- Tab bar + search -->
<div class="gm-toolbar flex shrink-0 items-center gap-3 px-3 py-2">
<div class="flex gap-1">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="gm-tab-btn rounded px-3 py-1 text-xs font-semibold uppercase tracking-widest transition"
:class="activeTab === tab.id ? 'gm-tab-btn--active' : ''"
@click="activeTab = tab.id"
>
{{ tab.label }}
</button>
</div>
<div class="relative flex-1">
<input
v-model="activeFilter"
class="gm-search-input w-full rounded border py-1 pl-7 pr-7 text-xs"
placeholder="Filter…"
type="search"
/>
<svg class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 opacity-40" width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001q.044.06.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1 1 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0" />
</svg>
<button
v-if="activeFilter"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] opacity-40 hover:opacity-80"
@click="activeFilter = ''"
>
</button>
</div>
<span class="gm-row-count shrink-0 font-mono text-xs tabular-nums opacity-60">
{{ activeRowCount }} / {{ activeTotalCount }}
</span>
</div>
<!-- Ships table -->
<div
v-show="activeTab === 'ships'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in shipTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
shipOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
shipOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="shipOrder.onDragStart($event, header.column.id)"
@dragover="shipOrder.onDragOver($event, header.column.id)"
@dragleave="shipOrder.onDragLeave()"
@drop="shipOrder.onDrop(header.column.id)"
@dragend="shipOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in shipTable.getRowModel().rows"
:key="row.id"
class="gm-tr cursor-pointer"
:class="isShipSelected(row.original.id) ? 'gm-tr--selected' : ''"
@click="onShipClick(row.original)"
@dblclick="onShipDblClick(row.original)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<span
v-if="cell.column.id === 'class'"
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
<tr v-if="shipTable.getRowModel().rows.length === 0">
<td :colspan="shipColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No ships match the filter.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Stations table -->
<div
v-show="activeTab === 'stations'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in stationTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
stationOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
stationOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="stationOrder.onDragStart($event, header.column.id)"
@dragover="stationOrder.onDragOver($event, header.column.id)"
@dragleave="stationOrder.onDragLeave()"
@drop="stationOrder.onDrop(header.column.id)"
@dragend="stationOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in stationTable.getRowModel().rows"
:key="row.id"
class="gm-tr cursor-pointer"
:class="isStationSelected(row.original.id) ? 'gm-tr--selected' : ''"
@click="onStationClick(row.original)"
@dblclick="onStationDblClick(row.original)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<span
v-if="cell.column.id === 'category'"
class="gm-badge rounded px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
<tr v-if="stationTable.getRowModel().rows.length === 0">
<td :colspan="stationColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No stations match the filter.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Factions table -->
<div
v-show="activeTab === 'factions'"
class="gm-table-container min-h-0 flex-1 overflow-auto"
>
<table class="gm-table w-full min-w-max border-separate border-spacing-0 text-xs">
<thead class="sticky top-0 z-10">
<tr
v-for="headerGroup in factionTable.getHeaderGroups()"
:key="headerGroup.id"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
class="gm-th whitespace-nowrap px-3 py-2 text-left"
:class="[
header.column.getCanSort() ? 'cursor-pointer' : '',
factionOrder.draggingId.value === header.column.id ? 'gm-th--dragging' : '',
factionOrder.overId.value === header.column.id ? 'gm-th--dragover' : '',
]"
draggable="true"
@dragstart="factionOrder.onDragStart($event, header.column.id)"
@dragover="factionOrder.onDragOver($event, header.column.id)"
@dragleave="factionOrder.onDragLeave()"
@drop="factionOrder.onDrop(header.column.id)"
@dragend="factionOrder.onDragEnd()"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<span class="flex select-none items-center gap-1">
<FlexRender
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span class="text-[10px] opacity-60">
{{ header.column.getIsSorted() === "asc" ? "↑" : header.column.getIsSorted() === "desc" ? "↓" : "" }}
</span>
</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in factionTable.getRowModel().rows"
:key="row.id"
class="gm-tr"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
class="gm-td whitespace-nowrap px-3 py-1.5"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</td>
</tr>
<tr v-if="factionTable.getRowModel().rows.length === 0">
<td :colspan="factionColumns.length" class="gm-td px-3 py-6 text-center opacity-40">
No factions match the filter.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</GmWindow>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const props = withDefaults(defineProps<{
title: string;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
}>(), {
initialX: 80,
initialY: 80,
initialWidth: 960,
initialHeight: 580,
});
const emit = defineEmits<{
close: [];
}>();
const windowEl = ref<HTMLDivElement | null>(null);
const x = ref(props.initialX);
const y = ref(props.initialY);
const w = ref(props.initialWidth);
const h = ref(props.initialHeight);
const isDragging = ref(false);
let dragOffsetX = 0;
let dragOffsetY = 0;
function onTitleMouseDown(e: MouseEvent) {
if ((e.target as HTMLElement).closest("button")) return;
// Snapshot current rendered size so resize isn't lost on drag
if (windowEl.value) {
w.value = windowEl.value.offsetWidth;
h.value = windowEl.value.offsetHeight;
}
isDragging.value = true;
dragOffsetX = e.clientX - x.value;
dragOffsetY = e.clientY - y.value;
e.preventDefault();
}
function onMouseMove(e: MouseEvent) {
if (!isDragging.value) return;
x.value = e.clientX - dragOffsetX;
y.value = e.clientY - dragOffsetY;
}
function onMouseUp() {
isDragging.value = false;
}
onMounted(() => {
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
});
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
</script>
<template>
<div
ref="windowEl"
class="gm-window pointer-events-auto fixed flex flex-col overflow-hidden rounded-xl border"
:style="{
left: `${x}px`,
top: `${y}px`,
width: `${w}px`,
height: `${h}px`,
cursor: isDragging ? 'grabbing' : 'default',
zIndex: 200,
}"
>
<!-- Title bar -->
<div
class="gm-window-titlebar flex shrink-0 cursor-grab select-none items-center gap-2 px-4 py-2.5"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
@mousedown="onTitleMouseDown"
>
<span class="gm-window-title-badge mr-1 rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-widest">GM</span>
<h2 class="flex-1 font-[Space_Grotesk] text-sm font-semibold tracking-wide">{{ title }}</h2>
<button
type="button"
class="gm-window-close-btn flex h-6 w-6 items-center justify-center rounded text-xs opacity-60 transition hover:opacity-100"
aria-label="Close window"
@click="emit('close')"
>
</button>
</div>
<!-- Content -->
<div class="min-h-0 flex-1 overflow-hidden">
<slot />
</div>
</div>
</template>