Refactor runtime bootstrap and ship control flows

This commit is contained in:
2026-04-03 01:12:26 -04:00
parent 0bb72bee35
commit 706e1cda8f
129 changed files with 9588 additions and 3548 deletions

View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { getShipBehaviorLabel } from "../shipAutomationPresentation";
import { useGmStore } from "../ui/stores/gmStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
import { useViewerSceneStore } from "../ui/stores/viewerScene";
import { useViewerSelectionStore, type ViewerSelectionSummary } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
type BrowserTab = "visible" | "owned";
interface BrowserItem {
key: string;
label: string;
subtitle: string;
meta?: string;
selection?: ViewerSelectionSummary;
focusSelection?: Selectable;
focusMode?: "follow" | "tactical";
}
interface BrowserSection {
key: string;
label: string;
count: number;
items: BrowserItem[];
}
const emit = defineEmits<{
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const gmStore = useGmStore();
const playerStore = usePlayerFactionStore();
const selectionStore = useViewerSelectionStore();
const sceneStore = useViewerSceneStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { playerFaction } = storeToRefs(playerStore);
const { activeSystemId, povLevel } = storeToRefs(sceneStore);
const activeTab = ref<BrowserTab>("visible");
const searchText = ref("");
function normalize(text: string) {
return text.trim().toLowerCase();
}
function matchesSearch(item: BrowserItem, search: string) {
if (!search) {
return true;
}
const haystack = `${item.label} ${item.subtitle} ${item.meta ?? ""}`.toLowerCase();
return haystack.includes(search);
}
function titleCase(value: string | null | undefined) {
if (!value) {
return "Unknown";
}
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (part) => part.toUpperCase());
}
function buildVisibleSections(): BrowserSection[] {
const sections: BrowserSection[] = [];
if (povLevel.value === "galaxy" || !activeSystemId.value) {
const systems = [...gmStore.systems]
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((system) => ({
key: `system-${system.id}`,
label: system.label,
subtitle: `${system.planets.length} planets · ${system.stars.length} stars`,
meta: system.id,
selection: { id: system.id, kind: "system", label: system.label },
focusSelection: { kind: "system", id: system.id },
focusMode: "tactical",
}));
sections.push({
key: "systems",
label: "Systems",
count: systems.length,
items: systems,
});
return sections;
}
const systemId = activeSystemId.value;
const ships = gmStore.ships
.filter((ship) => ship.systemId === systemId)
.sort((left, right) => left.name.localeCompare(right.name))
.map<BrowserItem>((ship) => ({
key: `ship-${ship.id}`,
label: ship.name,
subtitle: `${titleCase(ship.type)} · ${titleCase(ship.state)}`,
meta: `${getShipBehaviorLabel(ship.defaultBehavior.kind)}${ship.defaultBehavior.itemId ? ` · ${ship.defaultBehavior.itemId}` : ""}`,
selection: { id: ship.id, kind: "ship", label: ship.name },
focusSelection: { kind: "ship", id: ship.id },
focusMode: "follow",
}));
const stations = gmStore.stations
.filter((station) => station.systemId === systemId)
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((station) => ({
key: `station-${station.id}`,
label: station.label,
subtitle: `${titleCase(station.category)} · Docked ${station.dockedShips}/${station.dockingPads}`,
meta: station.factionId,
selection: { id: station.id, kind: "station", label: station.label },
focusSelection: { kind: "station", id: station.id },
focusMode: "tactical",
}));
sections.push({
key: "ships",
label: "Ships",
count: ships.length,
items: ships,
});
sections.push({
key: "stations",
label: "Stations",
count: stations.length,
items: stations,
});
return sections;
}
function buildOwnedSections(): BrowserSection[] {
const player = playerFaction.value;
if (!player) {
return [];
}
const ships = player.assetRegistry.shipIds
.map((shipId) => gmStore.ships.find((ship) => ship.id === shipId))
.filter((ship): ship is NonNullable<typeof ship> => ship != null)
.sort((left, right) => left.name.localeCompare(right.name))
.map<BrowserItem>((ship) => ({
key: `owned-ship-${ship.id}`,
label: ship.name,
subtitle: `${ship.systemId} · ${titleCase(ship.state)}`,
meta: getShipBehaviorLabel(ship.defaultBehavior.kind),
selection: { id: ship.id, kind: "ship", label: ship.name },
focusSelection: { kind: "ship", id: ship.id },
focusMode: "follow",
}));
const stations = player.assetRegistry.stationIds
.map((stationId) => gmStore.stations.find((station) => station.id === stationId))
.filter((station): station is NonNullable<typeof station> => station != null)
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((station) => ({
key: `owned-station-${station.id}`,
label: station.label,
subtitle: `${station.systemId} · ${titleCase(station.category)}`,
meta: `${station.installedModules.length} modules`,
selection: { id: station.id, kind: "station", label: station.label },
focusSelection: { kind: "station", id: station.id },
focusMode: "tactical",
}));
const fleets = player.fleets
.slice()
.sort((left, right) => left.label.localeCompare(right.label))
.map<BrowserItem>((fleet) => ({
key: `fleet-${fleet.id}`,
label: fleet.label,
subtitle: `${titleCase(fleet.role)} · ${titleCase(fleet.status)}`,
meta: `${fleet.assetIds.length} assets`,
}));
return [
{
key: "owned-fleets",
label: "Fleets",
count: fleets.length,
items: fleets,
},
{
key: "owned-stations",
label: "Stations",
count: stations.length,
items: stations,
},
{
key: "owned-ships",
label: "Ships",
count: ships.length,
items: ships,
},
];
}
const filteredSections = computed(() => {
const search = normalize(searchText.value);
const sections = activeTab.value === "visible" ? buildVisibleSections() : buildOwnedSections();
return sections
.map((section) => ({
...section,
items: section.items.filter((item) => matchesSearch(item, search)),
}))
.filter((section) => section.items.length > 0);
});
function selectItem(item: BrowserItem) {
if (!item.selection) {
return;
}
selectionStore.selectSelection(item.selection, "ui");
}
function focusItem(item: BrowserItem) {
if (item.selection) {
selectionStore.selectSelection(item.selection, "ui");
}
if (item.focusSelection) {
emit("focus", item.focusSelection, item.focusMode);
}
}
function isSelected(item: BrowserItem) {
return !!item.selection
&& item.selection.id === selectedEntityId.value
&& item.selection.kind === selectedEntityKind.value;
}
</script>
<template>
<section class="entity-browser-panel pointer-events-auto">
<div class="entity-browser-panel__header">
<div>
<div class="entity-browser-panel__kicker">Tactical View</div>
<h3>Entities</h3>
</div>
<div class="entity-browser-panel__context">
<span>{{ activeSystemId ?? "Galaxy" }}</span>
<span>{{ povLevel }}</span>
</div>
</div>
<div class="entity-browser-panel__tabs">
<button
type="button"
class="entity-browser-panel__tab"
:class="activeTab === 'visible' ? 'entity-browser-panel__tab--active' : ''"
@click="activeTab = 'visible'"
>
Visible
</button>
<button
type="button"
class="entity-browser-panel__tab"
:class="activeTab === 'owned' ? 'entity-browser-panel__tab--active' : ''"
@click="activeTab = 'owned'"
>
Owned
</button>
</div>
<input
v-model="searchText"
class="entity-browser-panel__search"
type="text"
placeholder="Filter entities"
>
<div v-if="activeTab === 'owned' && !playerFaction" class="entity-browser-panel__empty">
No player-owned assets yet.
</div>
<div v-else-if="filteredSections.length === 0" class="entity-browser-panel__empty">
Nothing matches the current view.
</div>
<div v-else class="entity-browser-panel__sections">
<section
v-for="section in filteredSections"
:key="section.key"
class="entity-browser-section"
>
<header class="entity-browser-section__header">
<span>{{ section.label }}</span>
<span>{{ section.items.length }}</span>
</header>
<div class="entity-browser-section__items">
<div
v-for="item in section.items"
:key="item.key"
class="entity-browser-item"
:class="isSelected(item) ? 'entity-browser-item--selected' : ''"
>
<button
type="button"
class="entity-browser-item__body"
:disabled="!item.selection"
@click="selectItem(item)"
>
<div class="entity-browser-item__label">{{ item.label }}</div>
<div class="entity-browser-item__subtitle">{{ item.subtitle }}</div>
<div v-if="item.meta" class="entity-browser-item__meta">{{ item.meta }}</div>
</button>
<button
v-if="item.focusSelection"
type="button"
class="entity-browser-item__focus"
@click.stop="focusItem(item)"
>
Focus
</button>
</div>
</div>
</section>
</div>
</section>
</template>