improvement on gm windows, ai

This commit is contained in:
2026-03-20 12:40:26 -04:00
parent ff078fe939
commit 3b56785f9a
39 changed files with 2594 additions and 358 deletions

View File

@@ -6,6 +6,8 @@ import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
import { createViewerHudState } from "./viewerHudState";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
@@ -22,6 +24,9 @@ const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false);
const gmTelemetryOpen = ref(false);
const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false);
onMounted(async () => {
await nextTick();
@@ -145,19 +150,56 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
/>
</div>
<button
type="button"
class="gm-console-toggle"
@click="gmOpsOpen = !gmOpsOpen"
>
{{ gmOpsOpen ? "Close" : "GM Console" }}
</button>
<div class="gm-launcher" @mouseleave="gmMenuOpen = false">
<div v-if="gmMenuOpen" class="gm-launcher-menu">
<button
type="button"
class="gm-launcher-item"
:class="gmOpsOpen ? 'gm-launcher-item--active' : ''"
@click="gmOpsOpen = !gmOpsOpen; gmMenuOpen = false"
>
Entities
</button>
<button
type="button"
class="gm-launcher-item"
:class="gmTelemetryOpen ? 'gm-launcher-item--active' : ''"
@click="gmTelemetryOpen = !gmTelemetryOpen; gmMenuOpen = false"
>
Telemetry
</button>
<button
type="button"
class="gm-launcher-item"
:class="gmSettingsOpen ? 'gm-launcher-item--active' : ''"
@click="gmSettingsOpen = !gmSettingsOpen; gmMenuOpen = false"
>
Settings
</button>
</div>
<button
type="button"
class="gm-launcher-trigger"
:class="gmMenuOpen ? 'gm-launcher-trigger--open' : ''"
@click="gmMenuOpen = !gmMenuOpen"
>
GM
</button>
</div>
<GmOpsWindow
v-if="gmOpsOpen"
@close="gmOpsOpen = false"
@focus="(id, kind) => onFocusSelection({ kind, id }, kind === 'ship' ? 'follow' : 'tactical')"
/>
<GmTelemetryWindow
v-if="gmTelemetryOpen"
@close="gmTelemetryOpen = false"
/>
<GmSettingsWindow
v-if="gmSettingsOpen"
@close="gmSettingsOpen = false"
/>
<div
ref="marqueeEl"

View File

@@ -1,4 +1,6 @@
import type { WorldDelta, WorldSnapshot } from "./contracts";
import type { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance";
export interface WorldStreamScope {
scopeKind?: string;
@@ -49,6 +51,34 @@ export function openWorldStream(
return stream;
}
export async function fetchTelemetry(signal?: AbortSignal) {
const response = await fetch("/api/telemetry", { signal });
if (!response.ok) {
throw new Error(`Telemetry request failed with ${response.status}`);
}
return response.json() as Promise<TelemetrySnapshot>;
}
export async function fetchBalance(signal?: AbortSignal) {
const response = await fetch("/api/balance", { signal });
if (!response.ok) {
throw new Error(`Balance request failed with ${response.status}`);
}
return response.json() as Promise<BalanceSettings>;
}
export async function updateBalance(settings: BalanceSettings) {
const response = await fetch("/api/balance", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error(`Balance update failed with ${response.status}`);
}
return response.json() as Promise<BalanceSettings>;
}
export async function resetWorld() {
const response = await fetch("/api/world/reset", {
method: "POST",

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, h, ref } from "vue";
import {
useVueTable,
getCoreRowModel,
@@ -18,6 +18,7 @@ import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
import type { MarketOrderSnapshot } from "../../contractsEconomy";
// ── Column ordering composable ─────────────────────────────────────────────
@@ -85,6 +86,20 @@ const factionMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.label])),
);
const factionColorMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.color])),
);
function renderColorCell(color: string | null | undefined) {
const resolved = color && color !== "—" ? color : "#6b7280";
return h("div", { class: "flex items-center justify-center" }, [
h("span", {
class: "inline-block h-3 w-3 rounded-full border border-white/20",
style: { backgroundColor: resolved },
}),
]);
}
function titleCaseToken(value: string | null | undefined) {
if (!value) return "—";
return value
@@ -106,6 +121,13 @@ function compactRate(value: number | null | undefined) {
return `${sign}${value.toFixed(2)}/s`;
}
function formatCargoAmount(value: number | null | undefined) {
if (value == null || Number.isNaN(value)) return "—";
const rounded = Math.round(value);
if (Math.abs(value - rounded) < 0.005) return String(rounded);
return value.toFixed(2).replace(/\.?0+$/, "");
}
function getLeadObjective(faction: FactionSnapshot) {
return [...(faction.objectives ?? [])]
.sort((left, right) => right.priority - left.priority)
@@ -184,6 +206,7 @@ type ShipRow = {
id: string;
label: string;
class: string;
factionColor: string;
faction: string;
system: string;
state: string;
@@ -201,6 +224,7 @@ const shipRows = computed<ShipRow[]>(() =>
id: s.id,
label: s.label,
class: s.class,
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
state: titleCaseToken(s.state),
@@ -218,6 +242,11 @@ const shipColumnHelper = createColumnHelper<ShipRow>();
const shipColumns = [
shipColumnHelper.accessor("label", { header: "Name" }),
shipColumnHelper.accessor("class", { header: "Class" }),
shipColumnHelper.accessor("factionColor", {
id: "factionColor",
header: "Color",
cell: (info) => renderColorCell(info.getValue()),
}),
shipColumnHelper.accessor("faction", { header: "Faction" }),
shipColumnHelper.accessor("system", { header: "System" }),
shipColumnHelper.accessor("state", { header: "Ship State" }),
@@ -226,13 +255,16 @@ const shipColumns = [
shipColumnHelper.accessor("phase", { header: "Phase" }),
shipColumnHelper.accessor("action", { header: "Current Action" }),
shipColumnHelper.accessor("task", { header: "Task" }),
shipColumnHelper.accessor("cargo", { header: "Cargo" }),
shipColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
shipColumnHelper.accessor("health", { header: "HP" }),
];
const shipFilter = ref("");
const shipSorting = ref<SortingState>([]);
const shipOrder = useColumnOrder(["label", "class", "faction", "system", "state", "objective", "behavior", "phase", "action", "task", "cargo", "health"]);
const shipOrder = useColumnOrder(["label", "class", "factionColor", "faction", "system", "state", "objective", "behavior", "phase", "action", "task", "cargo", "health"]);
const shipTable = useVueTable({
get data() { return shipRows.value; },
@@ -259,22 +291,29 @@ type StationRow = {
label: string;
category: string;
objective: string;
factionColor: string;
faction: string;
system: string;
process: string;
workforce: string;
docked: string;
orders: number;
orderDetails: MarketOrderSnapshot[];
cargo: number;
modules: number;
};
const marketOrderMap = computed(() =>
new Map(gmStore.marketOrders.map((o) => [o.id, o])),
);
const stationRows = computed<StationRow[]>(() =>
gmStore.stations.map((s) => ({
id: s.id,
label: s.label,
category: s.category,
objective: titleCaseToken(s.objective),
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
process: s.currentProcesses.length > 0
@@ -283,6 +322,10 @@ const stationRows = computed<StationRow[]>(() =>
workforce: `${Math.round(s.population)} / ${Math.round(s.populationCapacity)} · ${Math.round(s.workforceEffectiveRatio * 100)}%`,
docked: `${s.dockedShips} / ${s.dockingPads}`,
orders: s.marketOrderIds.length,
orderDetails: s.marketOrderIds.flatMap((id) => {
const order = marketOrderMap.value.get(id);
return order ? [order] : [];
}),
cargo: s.inventory.reduce((sum, e) => sum + e.amount, 0),
modules: s.installedModules.length,
})),
@@ -293,19 +336,27 @@ const stationColumns = [
stationColumnHelper.accessor("label", { header: "Name" }),
stationColumnHelper.accessor("category", { header: "Category" }),
stationColumnHelper.accessor("objective", { header: "Objective" }),
stationColumnHelper.accessor("factionColor", {
id: "factionColor",
header: "Color",
cell: (info) => renderColorCell(info.getValue()),
}),
stationColumnHelper.accessor("faction", { header: "Faction" }),
stationColumnHelper.accessor("system", { header: "System" }),
stationColumnHelper.accessor("process", { header: "Production" }),
stationColumnHelper.accessor("workforce", { header: "Workforce" }),
stationColumnHelper.accessor("docked", { header: "Docked" }),
stationColumnHelper.accessor("orders", { header: "Orders" }),
stationColumnHelper.accessor("cargo", { header: "Cargo" }),
stationColumnHelper.accessor("cargo", {
header: "Cargo",
cell: (info) => formatCargoAmount(info.getValue()),
}),
stationColumnHelper.accessor("modules", { header: "Modules" }),
];
const stationFilter = ref("");
const stationSorting = ref<SortingState>([]);
const stationOrder = useColumnOrder(["label", "category", "objective", "faction", "system", "process", "workforce", "docked", "orders", "cargo", "modules"]);
const stationOrder = useColumnOrder(["label", "category", "objective", "factionColor", "faction", "system", "process", "workforce", "docked", "orders", "cargo", "modules"]);
const stationTable = useVueTable({
get data() { return stationRows.value; },
@@ -330,6 +381,7 @@ const stationTable = useVueTable({
type FactionRow = {
id: string;
label: string;
color: string;
planCycle: number;
priority: string;
strategicState: string;
@@ -353,6 +405,7 @@ const factionRows = computed<FactionRow[]>(() =>
return {
id: f.id,
label: f.label,
color: f.color,
planCycle: blackboard?.planCycle ?? 0,
priority: describeFactionPriority(f),
strategicState: describeFactionStrategicState(f),
@@ -374,6 +427,10 @@ const factionRows = computed<FactionRow[]>(() =>
const factionColumnHelper = createColumnHelper<FactionRow>();
const factionColumns = [
factionColumnHelper.accessor("label", { header: "Faction" }),
factionColumnHelper.accessor("color", {
header: "Color",
cell: (info) => renderColorCell(info.getValue()),
}),
factionColumnHelper.accessor("planCycle", { header: "Cycle" }),
factionColumnHelper.accessor("priority", { header: "Top Priority" }),
factionColumnHelper.accessor("strategicState", { header: "Objective" }),
@@ -392,7 +449,7 @@ const factionColumns = [
const factionFilter = ref("");
const factionSorting = ref<SortingState>([]);
const factionOrder = useColumnOrder(["label", "planCycle", "priority", "strategicState", "leadStep", "leadTask", "warReadiness", "economy", "threat", "fleets", "systems", "credits", "population", "shipsBuilt", "shipsLost"]);
const factionOrder = useColumnOrder(["label", "color", "planCycle", "priority", "strategicState", "leadStep", "leadTask", "warReadiness", "economy", "threat", "fleets", "systems", "credits", "population", "shipsBuilt", "shipsLost"]);
const factionTable = useVueTable({
get data() { return factionRows.value; },
@@ -472,6 +529,31 @@ function isShipSelected(id: string) {
function isStationSelected(id: string) {
return selectedEntityKind.value === "station" && selectedEntityId.value === id;
}
// ── Orders tooltip ─────────────────────────────────────────────────────────
const ordersTooltip = ref<{
visible: boolean;
x: number;
y: number;
orders: MarketOrderSnapshot[];
}>({ visible: false, x: 0, y: 0, orders: [] });
function showOrdersTooltip(e: MouseEvent, orders: MarketOrderSnapshot[]) {
if (orders.length === 0) return;
ordersTooltip.value = { visible: true, x: e.clientX, y: e.clientY, orders };
}
function moveOrdersTooltip(e: MouseEvent) {
if (ordersTooltip.value.visible) {
ordersTooltip.value.x = e.clientX;
ordersTooltip.value.y = e.clientY;
}
}
function hideOrdersTooltip() {
ordersTooltip.value.visible = false;
}
</script>
<template>
@@ -660,6 +742,15 @@ function isStationSelected(id: string) {
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<span
v-else-if="cell.column.id === 'orders'"
:class="row.original.orderDetails.length > 0 ? 'gm-orders-trigger' : ''"
@mouseenter="showOrdersTooltip($event, row.original.orderDetails)"
@mousemove="moveOrdersTooltip"
@mouseleave="hideOrdersTooltip"
>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</span>
<FlexRender
v-else
:render="cell.column.columnDef.cell"
@@ -740,4 +831,35 @@ function isStationSelected(id: string) {
</div>
</div>
</GmWindow>
<Teleport to="body">
<div
v-if="ordersTooltip.visible && ordersTooltip.orders.length > 0"
class="gm-orders-tooltip"
:style="{ left: `${ordersTooltip.x + 14}px`, top: `${ordersTooltip.y + 14}px` }"
>
<table class="gm-orders-tooltip-table">
<thead>
<tr>
<th>Item</th>
<th>Kind</th>
<th>Amount</th>
<th>Remaining</th>
<th>Valuation</th>
<th>State</th>
</tr>
</thead>
<tbody>
<tr v-for="order in ordersTooltip.orders" :key="order.id">
<td>{{ order.itemId }}</td>
<td>{{ titleCaseToken(order.kind) }}</td>
<td class="tabular-nums">{{ order.amount }}</td>
<td class="tabular-nums">{{ order.remainingAmount }}</td>
<td class="tabular-nums">{{ order.valuation }}</td>
<td>{{ titleCaseToken(order.state) }}</td>
</tr>
</tbody>
</table>
</div>
</Teleport>
</template>

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from "vue";
import GmWindow from "./GmWindow.vue";
import { fetchBalance, updateBalance } from "../../api";
import type { BalanceSettings } from "../../contractsBalance";
const emit = defineEmits<{ close: [] }>();
type FieldMeta = {
key: keyof BalanceSettings;
label: string;
description: string;
step: number;
min: number;
};
const FIELDS: FieldMeta[] = [
{ key: "simulationSpeedMultiplier", label: "Sim Speed Multiplier", description: "Global speed factor applied to every tick", step: 0.1, min: 0.01 },
{ key: "arrivalThreshold", label: "Arrival Threshold", description: "Distance at which a ship is considered arrived", step: 1, min: 0.1 },
{ key: "miningRate", label: "Mining Rate", description: "Units of ore extracted per mining cycle", step: 1, min: 0 },
{ key: "miningCycleSeconds", label: "Mining Cycle (s)", description: "Duration of one mining cycle", step: 0.1, min: 0.1 },
{ key: "transferRate", label: "Transfer Rate", description: "Cargo units transferred per second", step: 1, min: 0 },
{ key: "dockingDuration", label: "Docking Duration (s)", description: "Time for a ship to complete docking", step: 0.1, min: 0.1 },
{ key: "undockingDuration", label: "Undocking Duration (s)", description: "Time for a ship to complete undocking", step: 0.1, min: 0.1 },
{ key: "undockDistance", label: "Undock Distance", description: "Distance traveled when undocking", step: 1, min: 0 },
{ key: "yPlane", label: "Y Plane", description: "Vertical height for spatial placement", step: 0.5, min: 0 },
];
const draft = reactive<BalanceSettings>({
simulationSpeedMultiplier: 1,
yPlane: 0,
arrivalThreshold: 0,
miningRate: 0,
miningCycleSeconds: 0,
transferRate: 0,
dockingDuration: 0,
undockingDuration: 0,
undockDistance: 0,
});
const loadError = ref<string | null>(null);
const saveError = ref<string | null>(null);
const saveStatus = ref<"idle" | "saving" | "saved">("idle");
const loaded = ref(false);
let savedTimer: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
try {
const data = await fetchBalance();
Object.assign(draft, data);
loadError.value = null;
loaded.value = true;
} catch {
loadError.value = "Failed to load settings";
}
});
function sanitizeDraft() {
for (const field of FIELDS) {
const value = draft[field.key];
draft[field.key] = Number.isFinite(value) ? Math.max(field.min, value) : field.min;
}
}
async function save() {
sanitizeDraft();
saveStatus.value = "saving";
saveError.value = null;
try {
const saved = await updateBalance({ ...draft });
Object.assign(draft, saved);
saveStatus.value = "saved";
if (savedTimer !== null) clearTimeout(savedTimer);
savedTimer = setTimeout(() => { saveStatus.value = "idle"; }, 2500);
} catch {
saveError.value = "Failed to save settings";
saveStatus.value = "idle";
}
}
</script>
<template>
<GmWindow
title="Settings"
:initial-width="480"
:initial-height="460"
:initial-x="260"
:initial-y="100"
@close="emit('close')"
>
<div class="gm-settings flex h-full flex-col">
<div v-if="loadError" class="gm-settings-error mx-4 mt-3 rounded px-3 py-2 text-xs">
{{ loadError }}
</div>
<div class="gm-settings-body min-h-0 flex-1 overflow-auto px-4 py-3">
<div class="gm-settings-section-title mb-3">Balance</div>
<div class="gm-settings-grid">
<template v-for="field in FIELDS" :key="field.key">
<label :for="`gm-setting-${field.key}`" class="gm-settings-label">
{{ field.label }}
</label>
<div class="gm-settings-field-group">
<input
:id="`gm-setting-${field.key}`"
v-model.number="draft[field.key]"
type="number"
class="gm-settings-input"
:step="field.step"
:min="field.min"
/>
<span class="gm-settings-desc">{{ field.description }}</span>
</div>
</template>
</div>
</div>
<div class="gm-settings-footer flex items-center gap-3 px-4 py-3">
<span v-if="saveError" class="gm-settings-error-inline flex-1 text-xs">{{ saveError }}</span>
<span v-else-if="saveStatus === 'saved'" class="gm-settings-saved flex-1 text-xs">Saved</span>
<span v-else class="flex-1" />
<button
type="button"
class="gm-settings-save-btn"
:disabled="saveStatus === 'saving' || !loaded"
@click="save"
>
{{ saveStatus === 'saving' ? 'Saving…' : 'Apply' }}
</button>
</div>
</div>
</GmWindow>
</template>

View File

@@ -0,0 +1,166 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import GmWindow from "./GmWindow.vue";
import { fetchTelemetry } from "../../api";
import type { TelemetrySnapshot } from "../../contractsTelemetry";
const emit = defineEmits<{ close: [] }>();
const data = ref<TelemetrySnapshot | null>(null);
const error = ref<string | null>(null);
const lastUpdatedAt = ref<number | null>(null);
const secondsSinceUpdate = ref(0);
let pollTimer: ReturnType<typeof setInterval> | null = null;
let ageTimer: ReturnType<typeof setInterval> | null = null;
async function poll() {
try {
data.value = await fetchTelemetry();
lastUpdatedAt.value = Date.now();
error.value = null;
} catch {
error.value = "Failed to fetch telemetry";
}
}
onMounted(() => {
void poll();
pollTimer = setInterval(poll, 2000);
ageTimer = setInterval(() => {
secondsSinceUpdate.value = lastUpdatedAt.value
? Math.floor((Date.now() - lastUpdatedAt.value) / 1000)
: 0;
}, 500);
});
onUnmounted(() => {
if (pollTimer !== null) clearInterval(pollTimer);
if (ageTimer !== null) clearInterval(ageTimer);
});
function formatUptime(seconds: number) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
function formatNumber(n: number) {
return n.toLocaleString("en-US");
}
function cpuBarWidth(pct: number) {
return `${Math.min(100, Math.max(0, pct))}%`;
}
function cpuBarClass(pct: number) {
if (pct >= 80) return "gm-telemetry-bar--high";
if (pct >= 50) return "gm-telemetry-bar--mid";
return "gm-telemetry-bar--low";
}
</script>
<template>
<GmWindow
title="Server Telemetry"
:initial-width="460"
:initial-height="380"
:initial-x="200"
:initial-y="120"
@close="emit('close')"
>
<div class="gm-telemetry flex h-full flex-col overflow-auto px-4 py-3">
<!-- Error state -->
<div v-if="error" class="gm-telemetry-error mb-3 rounded px-3 py-2 text-xs">
{{ error }}
</div>
<!-- Loading state -->
<div v-else-if="!data" class="flex flex-1 items-center justify-center text-xs opacity-40">
Loading
</div>
<template v-else>
<!-- PROCESS section -->
<div class="gm-telemetry-section mb-4">
<div class="gm-telemetry-section-title mb-2">Process</div>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<span class="gm-telemetry-label">CPU</span>
<span class="flex items-center gap-2">
<span class="gm-telemetry-value w-10 text-right">{{ data.process.cpuPercent.toFixed(1) }}%</span>
<span class="gm-telemetry-bar-track flex-1">
<span
class="gm-telemetry-bar"
:class="cpuBarClass(data.process.cpuPercent)"
:style="{ width: cpuBarWidth(data.process.cpuPercent) }"
/>
</span>
<span class="gm-telemetry-dim">/ {{ data.process.processorCount }} cores</span>
</span>
<span class="gm-telemetry-label">Working set</span>
<span class="gm-telemetry-value">{{ data.process.workingSetMb.toFixed(1) }} MB</span>
<span class="gm-telemetry-label">GC memory</span>
<span class="gm-telemetry-value">{{ data.process.gcMemoryMb.toFixed(1) }} MB</span>
<span class="gm-telemetry-label">Threads</span>
<span class="gm-telemetry-value">{{ data.process.threadCount }}</span>
<span class="gm-telemetry-label">Uptime</span>
<span class="gm-telemetry-value">{{ formatUptime(data.process.uptimeSeconds) }}</span>
</div>
</div>
<!-- SIMULATION section -->
<div class="gm-telemetry-section mb-4">
<div class="gm-telemetry-section-title mb-2">Simulation</div>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<span class="gm-telemetry-label">Sequence</span>
<span class="gm-telemetry-value font-mono">{{ formatNumber(data.simulation.sequence) }}</span>
<span class="gm-telemetry-label">Connected clients</span>
<span class="gm-telemetry-value">
<span
class="gm-telemetry-clients-dot"
:class="data.simulation.connectedClients > 0 ? 'gm-telemetry-clients-dot--active' : ''"
/>
{{ data.simulation.connectedClients }}
</span>
<span class="gm-telemetry-label">Delta history</span>
<span class="gm-telemetry-value">{{ data.simulation.deltaHistoryCount }} / 256</span>
<span class="gm-telemetry-label">Tick interval</span>
<span class="gm-telemetry-value">{{ data.simulation.tickIntervalMs }} ms</span>
</div>
</div>
<!-- RUNTIME section -->
<div class="gm-telemetry-section mb-4">
<div class="gm-telemetry-section-title mb-2">Runtime</div>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
<span class="gm-telemetry-label">.NET</span>
<span class="gm-telemetry-value">{{ data.runtime.frameworkDescription }}</span>
<span class="gm-telemetry-label">GC collections</span>
<span class="gm-telemetry-value font-mono">
G0 {{ formatNumber(data.runtime.gcGen0) }} &nbsp;·&nbsp;
G1 {{ formatNumber(data.runtime.gcGen1) }} &nbsp;·&nbsp;
G2 {{ formatNumber(data.runtime.gcGen2) }}
</span>
</div>
</div>
<!-- Footer -->
<div class="mt-auto flex items-center justify-between pt-2 text-[10px] opacity-40">
<span>Updated {{ secondsSinceUpdate }}s ago</span>
<span>Polling every 2s</span>
</div>
</template>
</div>
</GmWindow>
</template>

View File

@@ -0,0 +1,11 @@
export interface BalanceSettings {
simulationSpeedMultiplier: number;
yPlane: number;
arrivalThreshold: number;
miningRate: number;
miningCycleSeconds: number;
transferRate: number;
dockingDuration: number;
undockingDuration: number;
undockDistance: number;
}

View File

@@ -0,0 +1,23 @@
export interface TelemetrySnapshot {
process: {
uptimeSeconds: number;
cpuPercent: number;
workingSetMb: number;
gcMemoryMb: number;
threadCount: number;
processorCount: number;
};
simulation: {
sequence: number;
connectedClients: number;
deltaHistoryCount: number;
tickIntervalMs: number;
};
runtime: {
frameworkDescription: string;
osDescription: string;
gcGen0: number;
gcGen1: number;
gcGen2: number;
};
}

View File

@@ -539,28 +539,326 @@ canvas {
box-shadow: inset 2px 0 0 var(--viewer-accent);
}
.gm-console-toggle {
.gm-launcher {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
pointer-events: auto;
z-index: 100;
padding: 7px 18px;
z-index: 300;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.gm-launcher-trigger {
padding: 7px 24px;
border-radius: 999px;
background: rgba(127, 214, 255, 0.1);
border: 1px solid rgba(127, 214, 255, 0.24);
color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
letter-spacing: 0.12em;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
cursor: pointer;
transition: background 140ms ease, border-color 140ms ease;
backdrop-filter: blur(8px);
}
.gm-console-toggle:hover {
.gm-launcher-trigger:hover,
.gm-launcher-trigger--open {
background: rgba(127, 214, 255, 0.18);
border-color: rgba(127, 214, 255, 0.4);
}
.gm-launcher-menu {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 4px;
backdrop-filter: blur(12px);
background: rgba(7, 14, 27, 0.88);
border: 1px solid rgba(132, 196, 255, 0.18);
border-radius: 10px;
padding: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.gm-launcher-item {
padding: 6px 16px;
border-radius: 6px;
background: transparent;
border: 1px solid transparent;
color: var(--viewer-muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.7rem;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
text-align: left;
transition: background 100ms ease, color 100ms ease, border-color 100ms ease;
white-space: nowrap;
}
.gm-launcher-item:hover {
background: rgba(127, 214, 255, 0.08);
color: var(--viewer-text);
}
.gm-launcher-item--active {
background: rgba(127, 214, 255, 0.12);
border-color: rgba(127, 214, 255, 0.22);
color: var(--viewer-accent);
}
.gm-orders-trigger {
cursor: default;
border-bottom: 1px dashed rgba(127, 214, 255, 0.4);
}
.gm-orders-tooltip {
position: fixed;
z-index: 9999;
pointer-events: none;
backdrop-filter: blur(12px);
background: rgba(7, 14, 27, 0.95);
border: 1px solid rgba(132, 196, 255, 0.22);
border-radius: 4px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 6px 0;
font-family: "IBM Plex Mono", monospace;
font-size: 0.68rem;
color: var(--viewer-muted);
max-width: 560px;
}
.gm-orders-tooltip-table {
border-collapse: collapse;
width: 100%;
}
.gm-orders-tooltip-table th {
color: var(--viewer-accent);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.62rem;
padding: 2px 12px 4px;
text-align: left;
border-bottom: 1px solid rgba(132, 196, 255, 0.14);
white-space: nowrap;
}
.gm-orders-tooltip-table td {
padding: 3px 12px;
white-space: nowrap;
}
.gm-orders-tooltip-table tr:nth-child(even) td {
background: rgba(127, 214, 255, 0.03);
}
/* ── GM Telemetry Window ─────────────────────────────────────────────────── */
.gm-telemetry {
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
color: var(--viewer-muted);
}
.gm-telemetry-error {
background: rgba(255, 80, 60, 0.12);
border: 1px solid rgba(255, 80, 60, 0.22);
color: rgba(255, 160, 140, 0.9);
}
.gm-telemetry-section-title {
color: var(--viewer-accent);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
border-bottom: 1px solid rgba(132, 196, 255, 0.12);
padding-bottom: 4px;
}
.gm-telemetry-label {
color: var(--viewer-muted);
white-space: nowrap;
opacity: 0.7;
}
.gm-telemetry-value {
color: var(--viewer-text);
font-size: 0.72rem;
}
.gm-telemetry-dim {
color: var(--viewer-muted);
font-size: 0.65rem;
opacity: 0.6;
}
.gm-telemetry-bar-track {
display: block;
height: 4px;
border-radius: 2px;
background: rgba(127, 214, 255, 0.08);
overflow: hidden;
min-width: 60px;
}
.gm-telemetry-bar {
display: block;
height: 100%;
border-radius: 2px;
transition: width 600ms ease;
}
.gm-telemetry-bar--low {
background: rgba(127, 214, 255, 0.6);
}
.gm-telemetry-bar--mid {
background: rgba(255, 191, 105, 0.7);
}
.gm-telemetry-bar--high {
background: rgba(255, 80, 60, 0.75);
}
.gm-telemetry-clients-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(127, 214, 255, 0.2);
margin-right: 4px;
vertical-align: middle;
position: relative;
top: -1px;
}
.gm-telemetry-clients-dot--active {
background: rgba(100, 220, 130, 0.8);
box-shadow: 0 0 4px rgba(100, 220, 130, 0.5);
}
/* ── GM Settings Window ──────────────────────────────────────────────────── */
.gm-settings {
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
color: var(--viewer-muted);
}
.gm-settings-body {
scrollbar-width: thin;
scrollbar-color: rgba(127, 214, 255, 0.2) transparent;
}
.gm-settings-section-title {
color: var(--viewer-accent);
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
border-bottom: 1px solid rgba(132, 196, 255, 0.12);
padding-bottom: 4px;
}
.gm-settings-grid {
display: grid;
grid-template-columns: 180px 1fr;
gap: 0;
}
.gm-settings-label {
display: flex;
align-items: center;
padding: 6px 12px 6px 0;
color: var(--viewer-muted);
font-size: 0.7rem;
border-bottom: 1px solid rgba(132, 196, 255, 0.06);
cursor: default;
}
.gm-settings-field-group {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
padding: 5px 0;
border-bottom: 1px solid rgba(132, 196, 255, 0.06);
}
.gm-settings-input {
background: rgba(127, 214, 255, 0.05);
border: 1px solid rgba(132, 196, 255, 0.16);
border-radius: 4px;
color: var(--viewer-text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.72rem;
outline: none;
padding: 3px 8px;
width: 120px;
transition: border-color 120ms ease, background 120ms ease;
}
.gm-settings-input:focus {
border-color: rgba(127, 214, 255, 0.4);
background: rgba(127, 214, 255, 0.08);
}
.gm-settings-desc {
color: var(--viewer-muted);
font-size: 0.62rem;
opacity: 0.5;
}
.gm-settings-footer {
border-top: 1px solid rgba(132, 196, 255, 0.1);
background: rgba(127, 214, 255, 0.02);
}
.gm-settings-error {
background: rgba(255, 80, 60, 0.12);
border: 1px solid rgba(255, 80, 60, 0.22);
color: rgba(255, 160, 140, 0.9);
}
.gm-settings-error-inline {
color: rgba(255, 140, 120, 0.9);
}
.gm-settings-saved {
color: rgba(100, 220, 130, 0.85);
}
.gm-settings-save-btn {
padding: 5px 18px;
border-radius: 6px;
background: rgba(127, 214, 255, 0.12);
border: 1px solid rgba(127, 214, 255, 0.28);
color: var(--viewer-accent);
font-family: "IBM Plex Mono", monospace;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.gm-settings-save-btn:hover:not(:disabled) {
background: rgba(127, 214, 255, 0.2);
border-color: rgba(127, 214, 255, 0.45);
}
.gm-settings-save-btn:disabled {
opacity: 0.45;
cursor: default;
}

View File

@@ -2,22 +2,26 @@ import { defineStore } from "pinia";
import type { ShipSnapshot } from "../../contractsShips";
import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
import type { MarketOrderSnapshot } from "../../contractsEconomy";
export const useGmStore = defineStore("gm", {
state: () => ({
ships: [] as ShipSnapshot[],
stations: [] as StationSnapshot[],
factions: [] as FactionSnapshot[],
marketOrders: [] as MarketOrderSnapshot[],
}),
actions: {
updateWorld(
ships: ShipSnapshot[],
stations: StationSnapshot[],
factions: FactionSnapshot[],
marketOrders: MarketOrderSnapshot[],
) {
this.ships = ships;
this.stations = stations;
this.factions = factions;
this.marketOrders = marketOrders;
},
},
});

View File

@@ -11,6 +11,41 @@ import itemsData from "../../../shared/data/items.json";
const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]),
);
const moduleProductionById = new Map<string, {
cycleSeconds: number;
inputs: { itemId: string; amount: number }[];
outputs: { itemId: string; amount: number }[];
}>(
(modulesData as { id: string; product?: string[] }[])
.flatMap((module) => {
const productItemId = module.product?.[0];
if (!productItemId) {
return [];
}
const item = (itemsData as {
id: string;
production?: { time: number; amount: number; method?: string; wares?: { ware?: string; itemId?: string; amount: number }[] }[];
}[]).find((candidate) => candidate.id === productItemId);
const production = item?.production?.find((recipe) => (recipe.method ?? "default") === "default")
?? item?.production?.[0];
if (!production) {
return [];
}
return [[module.id, {
cycleSeconds: production.time,
inputs: (production.wares ?? []).map((ware) => ({
itemId: ware.ware ?? ware.itemId ?? "unknown",
amount: ware.amount,
})),
outputs: [{
itemId: productItemId,
amount: production.amount,
}],
}] as const];
}),
);
const itemTransportById = new Map<string, string>(
(itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]),
);
@@ -62,6 +97,23 @@ function renderProgressBar(progress: number): string {
return `<div class="detail-progress"><div class="detail-progress-track"><div class="detail-progress-fill" style="width: ${(progress * 100).toFixed(1)}%"></div></div></div>`;
}
function buildStaticModuleTooltip(world: WorldState, stationId: string, moduleId: string): string | null {
const production = moduleProductionById.get(moduleId);
if (!production) {
return null;
}
const stationInventory = world.stations.get(stationId)?.inventory ?? [];
const inputLines = production.inputs
.map((entry) => ` ${entry.itemId}: ${entry.amount.toFixed(0)} required / ${inventoryAmount(stationInventory, entry.itemId).toFixed(0)} available`)
.join("\n");
const outputLines = production.outputs
.map((entry) => ` ${entry.itemId}: ${entry.amount.toFixed(0)}`)
.join("\n");
return `Cycle: ${formatDuration(production.cycleSeconds)}\nInputs:\n${inputLines || " none"}\nOutputs:\n${outputLines || " none"}`;
}
function formatModuleListWithConstruction(
world: WorldState,
stationId: string,
@@ -91,7 +143,10 @@ function formatModuleListWithConstruction(
renderedProcessCount.set(moduleId, processIndex + 1);
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
if (!process) {
return moduleName;
const tooltip = buildStaticModuleTooltip(world, stationId, moduleId);
return tooltip
? `<div class="detail-progress-label" title="${escapeAttr(tooltip)}"><span>${moduleName}</span><span>idle</span></div>`
: `<div class="detail-progress-label"><span>${moduleName}</span></div>`;
}
const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n");

View File

@@ -204,6 +204,7 @@ export class ViewerWorldLifecycle {
[...world.ships.values()],
[...world.stations.values()],
[...world.factions.values()],
[...world.marketOrders.values()],
);
}
}