improvement on gm windows, ai
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
134
apps/viewer/src/components/gm/GmSettingsWindow.vue
Normal file
134
apps/viewer/src/components/gm/GmSettingsWindow.vue
Normal 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>
|
||||
166
apps/viewer/src/components/gm/GmTelemetryWindow.vue
Normal file
166
apps/viewer/src/components/gm/GmTelemetryWindow.vue
Normal 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) }} ·
|
||||
G1 {{ formatNumber(data.runtime.gcGen1) }} ·
|
||||
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>
|
||||
11
apps/viewer/src/contractsBalance.ts
Normal file
11
apps/viewer/src/contractsBalance.ts
Normal 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;
|
||||
}
|
||||
23
apps/viewer/src/contractsTelemetry.ts
Normal file
23
apps/viewer/src/contractsTelemetry.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -204,6 +204,7 @@ export class ViewerWorldLifecycle {
|
||||
[...world.ships.values()],
|
||||
[...world.stations.values()],
|
||||
[...world.factions.values()],
|
||||
[...world.marketOrders.values()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user