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

@@ -1,14 +1,21 @@
<script setup lang="ts">
import { storeToRefs } from "pinia";
import { nextTick, onBeforeUnmount, onMounted, ref } from "vue";
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { GameViewer } from "./GameViewer";
import CollapsibleHudPanel from "./components/CollapsibleHudPanel.vue";
import HtmlInfoPanel from "./components/HtmlInfoPanel.vue";
import ViewerHistoryLayer from "./components/ViewerHistoryLayer.vue";
import ViewerEntityBrowserPanel from "./components/ViewerEntityBrowserPanel.vue";
import ViewerEntityInspectorPanel from "./components/ViewerEntityInspectorPanel.vue";
import ViewerShipOrderContextMenu from "./components/ViewerShipOrderContextMenu.vue";
import GmOpsWindow from "./components/gm/GmOpsWindow.vue";
import GmTelemetryWindow from "./components/gm/GmTelemetryWindow.vue";
import GmSettingsWindow from "./components/gm/GmSettingsWindow.vue";
import AuthSessionPanel from "./components/AuthSessionPanel.vue";
import AuthLandingPage from "./components/AuthLandingPage.vue";
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
import { createViewerHudState } from "./viewerHudState";
import { useAuthStore } from "./ui/stores/authStore";
import { useViewerSelectionStore } from "./ui/stores/viewerSelection";
import type { Selectable } from "./viewerTypes";
@@ -19,8 +26,11 @@ const hoverLabelEl = ref<HTMLDivElement | null>(null);
const hoverConnectorLineEl = ref<SVGLineElement | null>(null);
const hudState = createViewerHudState();
const authStore = useAuthStore();
const automationCatalogStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityLabel } = storeToRefs(selectionStore);
const { canAccessGm } = storeToRefs(authStore);
let viewer: GameViewer | undefined;
const gmOpsOpen = ref(false);
@@ -29,6 +39,47 @@ const gmSettingsOpen = ref(false);
const gmMenuOpen = ref(false);
onMounted(async () => {
void automationCatalogStore.load();
await startViewerIfAuthenticated();
});
onBeforeUnmount(() => {
viewer?.dispose();
});
watch(() => authStore.isAuthenticated, async (isAuthenticated) => {
if (isAuthenticated) {
await startViewerIfAuthenticated();
return;
}
viewer?.dispose();
viewer = undefined;
});
function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
if (!windowState) {
return;
}
windowState.width = width;
windowState.height = height;
}
function onOpenHistory(selection: Selectable) {
viewer?.openHistoryWindow(selection);
}
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
viewer?.focusSelection(selection, cameraMode);
}
async function startViewerIfAuthenticated() {
if (!authStore.isAuthenticated || viewer) {
return;
}
await nextTick();
if (
!canvasHostEl.value
@@ -49,39 +100,19 @@ onMounted(async () => {
hoverConnectorLineEl: hoverConnectorLineEl.value,
});
void viewer.start();
});
onBeforeUnmount(() => {
viewer?.dispose();
});
function onHistoryWindowResize(id: string, width: number, height: number) {
const windowState = hudState.historyWindows.find((entry) => entry.id === id);
if (!windowState) {
return;
}
windowState.width = width;
windowState.height = height;
}
function onOpenHistory(selection: Selectable) {
viewer?.openHistoryWindow(selection);
}
function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactical") {
viewer?.focusSelection(selection, cameraMode);
}
</script>
<template>
<div class="viewer-app">
<AuthLandingPage v-if="!authStore.isAuthenticated" />
<div v-else class="viewer-app">
<div
ref="canvasHostEl"
class="viewer-canvas-host"
/>
<div class="pointer-events-none fixed inset-0">
<div class="absolute left-5 top-5 flex w-[min(360px,calc(100vw-40px))] flex-col gap-4 max-[760px]:right-5 max-[760px]:w-auto">
<div class="absolute left-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(360px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:right-5 max-[760px]:bottom-[148px] max-[760px]:w-auto max-[760px]:max-h-[38vh]">
<AuthSessionPanel />
<CollapsibleHudPanel
v-model:collapsed="hudState.gamePanel.collapsed"
class-name="topbar"
@@ -106,9 +137,13 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
:summary="hudState.performancePanel.summary"
:body-text="hudState.performancePanel.bodyText"
/>
<ViewerEntityBrowserPanel
class="min-h-0 flex-1"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
</div>
<div class="absolute right-5 top-5 flex w-[min(380px,calc(100vw-40px))] flex-col gap-4 max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto max-[760px]:overflow-auto">
<div class="absolute right-5 top-5 flex max-h-[calc(100vh-40px)] w-[min(380px,calc(100vw-40px))] flex-col gap-4 overflow-hidden max-[760px]:bottom-[148px] max-[760px]:left-5 max-[760px]:right-5 max-[760px]:top-auto max-[760px]:max-h-[38vh] max-[760px]:w-auto">
<HtmlInfoPanel
class-name="system-panel-section"
title="System"
@@ -118,14 +153,11 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
subtitle-class="system-title"
body-class="system-body"
/>
<HtmlInfoPanel
class-name="detail-panel-section"
title="Focus"
:subtitle="hudState.detailPanel.title"
:body-html="hudState.detailPanel.bodyHtml"
:hidden="hudState.detailPanel.hidden"
subtitle-class="detail-title"
body-class="detail-body"
<ViewerEntityInspectorPanel
class="min-h-0 flex-1"
:fallback-title="hudState.detailPanel.title"
:fallback-html="hudState.detailPanel.bodyHtml"
@focus="(selection, cameraMode) => onFocusSelection(selection, cameraMode)"
/>
<div
class="pointer-events-auto rounded-xl bg-[rgba(255,116,88,0.14)] px-3.5 py-3 text-[#ffd8cf]"
@@ -150,7 +182,7 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
/>
</div>
<div class="gm-launcher" @mouseleave="gmMenuOpen = false">
<div v-if="canAccessGm" class="gm-launcher" @mouseleave="gmMenuOpen = false">
<div v-if="gmMenuOpen" class="gm-launcher-menu">
<button
type="button"
@@ -239,6 +271,8 @@ function onFocusSelection(selection: Selectable, cameraMode?: "follow" | "tactic
>
{{ hudState.hoverLabel.text }}
</div>
<ViewerShipOrderContextMenu />
</div>
</div>
</template>

View File

@@ -31,6 +31,9 @@ import { LocalLayer } from "./viewerLocalLayer";
import type { HistoryWindowState, ViewerHudBindings, ViewerHudState } from "./viewerHudState";
import { describeSelectable } from "./viewerSelection";
import { entityIdToSelectable, selectionToEntityId, type ViewerSelectionStore } from "./ui/stores/viewerSelection";
import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
import { viewerPinia } from "./ui/stores/pinia";
import type { FactionSnapshot } from "./contracts";
import type {
CameraMode,
@@ -68,6 +71,8 @@ export class ViewerAppController {
readonly hudState: ViewerHudState;
readonly selectionStore: ViewerSelectionStore;
private readonly sceneStore = useViewerSceneStore(viewerPinia);
private readonly orderContextMenuStore = useViewerOrderContextMenuStore(viewerPinia);
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
@@ -156,6 +161,8 @@ export class ViewerAppController {
this.disposeEventBindings();
this.unsubscribeSelectionStore();
this.stream?.close();
this.sceneStore.reset();
this.orderContextMenuStore.close();
this.renderSurface.dispose();
disposeSceneResources(this.universeLayer.scene);
disposeSceneResources(this.galaxyLayer.scene);
@@ -206,6 +213,7 @@ export class ViewerAppController {
}
private applySelectedItems(items: Selectable[], source: "viewer" | "ui") {
this.orderContextMenuStore.close();
this.selectedItems = items;
if (items.length === 1) {
const selection = items[0];
@@ -224,6 +232,7 @@ export class ViewerAppController {
kind: Selectable["kind"] | null,
entityId: string | null,
) {
this.orderContextMenuStore.close();
const selection = entityIdToSelectable(kind, entityId);
this.selectedItems = selection ? [selection] : [];
this.navigationController.syncFollowStateFromSelection();
@@ -270,6 +279,9 @@ export class ViewerAppController {
this.currentDistance = nextState.currentDistance;
this.povLevel = nextState.povLevel;
this.orbitPitch = nextState.orbitPitch;
if (this.sceneStore.povLevel !== this.povLevel) {
this.sceneStore.setViewContext(this.activeSystemId ?? null, this.povLevel);
}
this.navigationController.updateActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {

View File

@@ -2,7 +2,12 @@ import type { WorldDelta, WorldSnapshot } from "./contracts";
import type { TelemetrySnapshot } from "./contractsTelemetry";
import type { BalanceSettings } from "./contractsBalance";
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
import type { AuthSessionResponse, ForgotPasswordResponse } from "./contractsAuth";
import type { ShipAutomationCatalogSnapshot } from "./contractsShipAutomation";
import type { FactionSnapshot } from "./contractsFactions";
import type { ShipSnapshot } from "./contractsShips";
import type { StationSnapshot } from "./contractsInfrastructure";
import { clearAuthSession, getAuthSession, setAuthSession } from "./authSession";
import type {
PlayerAssetAssignmentCommandRequest,
PlayerAutomationPolicyCommandRequest,
@@ -23,16 +28,54 @@ export interface WorldStreamScope {
bubbleId?: string | null;
}
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
async function fetchJson<T>(input: RequestInfo | URL, init?: RequestInit, options?: { skipAuth?: boolean; skipRefresh?: boolean }): Promise<T> {
const headers = new Headers(init?.headers);
if (!options?.skipAuth) {
const session = getAuthSession();
if (session?.accessToken) {
headers.set("Authorization", `Bearer ${session.accessToken}`);
}
}
const response = await fetch(input, {
...init,
headers,
});
if (response.status === 401 && !options?.skipAuth && !options?.skipRefresh) {
const refreshed = await tryRefreshSession();
if (refreshed) {
return fetchJson<T>(input, init, { skipRefresh: true });
}
}
if (!response.ok) {
throw new Error(`${init?.method ?? "GET"} ${typeof input === "string" ? input : input.toString()} failed with ${response.status}`);
}
return response.json() as Promise<T>;
}
async function tryRefreshSession(): Promise<boolean> {
const session = getAuthSession();
if (!session?.refreshToken) {
return false;
}
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: session.refreshToken }),
});
if (!response.ok) {
clearAuthSession();
return false;
}
const nextSession = await response.json() as AuthSessionResponse;
setAuthSession(nextSession);
return true;
}
export async function fetchWorldSnapshot(signal?: AbortSignal) {
return fetchJson<WorldSnapshot>("/api/world", { signal });
return fetchJson<WorldSnapshot>("/api/world", { signal }, { skipAuth: true });
}
export function openWorldStream(
@@ -86,16 +129,80 @@ export async function updateBalance(settings: BalanceSettings) {
});
}
export async function createFaction(request: { factionId: string }) {
return fetchJson<FactionSnapshot>("/api/gm/factions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}
export async function spawnShip(request: { factionId: string; systemId: string; shipId?: string | null; behaviorKind?: string | null }) {
return fetchJson<ShipSnapshot>("/api/gm/ships", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}
export async function spawnStation(request: { factionId: string; systemId: string; objective?: string | null; label?: string | null }) {
return fetchJson<StationSnapshot>("/api/gm/stations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
});
}
export async function resetWorld() {
return fetchJson<WorldSnapshot>("/api/world/reset", {
method: "POST",
});
}
export async function register(request: { email: string; password: string }) {
const session = await fetchJson<AuthSessionResponse>("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true });
setAuthSession(session);
return session;
}
export async function login(request: { email: string; password: string }) {
const session = await fetchJson<AuthSessionResponse>("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true });
setAuthSession(session);
return session;
}
export async function forgotPassword(request: { email: string }) {
return fetchJson<ForgotPasswordResponse>("/api/auth/forgot-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true });
}
export async function resetPassword(request: { token: string; newPassword: string }) {
await fetchJson<void>("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(request),
}, { skipAuth: true, skipRefresh: true });
}
export async function fetchPlayerFaction(signal?: AbortSignal) {
return fetchJson<PlayerFactionSnapshot>("/api/player-faction", { signal });
}
export async function fetchShipAutomationCatalog(signal?: AbortSignal) {
return fetchJson<ShipAutomationCatalogSnapshot>("/api/ships/catalog", { signal }, { skipAuth: true });
}
export async function createPlayerOrganization(request: PlayerOrganizationCommandRequest) {
return fetchJson<PlayerFactionSnapshot>("/api/player-faction/organizations", {
method: "POST",
@@ -182,3 +289,9 @@ export async function updateShipDefaultBehavior(shipId: string, request: ShipDef
body: JSON.stringify(request),
});
}
export async function removeShipOrder(shipId: string, orderId: string) {
return fetchJson<ShipSnapshot>(`/api/ships/${shipId}/orders/${orderId}`, {
method: "DELETE",
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

View File

@@ -0,0 +1,76 @@
import type { AuthSessionResponse } from "./contractsAuth";
const STORAGE_KEY = "space-game.auth.session";
export interface AuthSession {
userId: string;
email: string;
roles: string[];
accessToken: string;
accessTokenExpiresAtUtc: string;
refreshToken: string;
refreshTokenExpiresAtUtc: string;
}
let currentSession: AuthSession | null = loadStoredSession();
const listeners = new Set<(session: AuthSession | null) => void>();
export function getAuthSession(): AuthSession | null {
return currentSession;
}
export function setAuthSession(session: AuthSessionResponse | null) {
currentSession = session ? { ...session } : null;
persistSession(currentSession);
notifyListeners();
}
export function clearAuthSession() {
currentSession = null;
persistSession(null);
notifyListeners();
}
export function subscribeToAuthSession(listener: (session: AuthSession | null) => void) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function loadStoredSession(): AuthSession | null {
if (typeof window === "undefined") {
return null;
}
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as AuthSession;
return parsed?.accessToken && parsed?.refreshToken
? { ...parsed, roles: Array.isArray(parsed.roles) ? parsed.roles : [] }
: null;
} catch {
return null;
}
}
function persistSession(session: AuthSession | null) {
if (typeof window === "undefined") {
return;
}
if (!session) {
window.localStorage.removeItem(STORAGE_KEY);
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
function notifyListeners() {
for (const listener of listeners) {
listener(currentSession);
}
}

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { forgotPassword, login, register, resetPassword } from "../api";
import { useAuthStore } from "../ui/stores/authStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
type AuthPane = "login" | "register" | "forgot" | "reset";
const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const pane = ref<AuthPane>("login");
const errorMessage = ref("");
const infoMessage = ref("");
const returnedResetToken = ref("");
const loginForm = reactive({
email: "",
password: "",
});
const registerForm = reactive({
email: "",
password: "",
});
const forgotForm = reactive({
email: "",
});
const resetForm = reactive({
token: "",
newPassword: "",
});
const paneTitle = computed(() => {
switch (pane.value) {
case "register":
return "Create your pilot account";
case "forgot":
return "Recover access";
case "reset":
return "Choose a new password";
default:
return "Sign in to the command bridge";
}
});
async function submitLogin() {
await execute(async () => {
const session = await login(loginForm);
authStore.setSession(session);
playerFactionStore.setPlayerFaction(null);
});
}
async function submitRegister() {
await execute(async () => {
const session = await register(registerForm);
authStore.setSession(session);
playerFactionStore.setPlayerFaction(null);
});
}
async function submitForgot() {
await execute(async () => {
const response = await forgotPassword(forgotForm);
returnedResetToken.value = response.resetToken ?? "";
if (response.resetToken) {
resetForm.token = response.resetToken;
resetForm.newPassword = "";
infoMessage.value = "Development reset token generated below. Continue directly to reset password.";
pane.value = "reset";
return;
}
infoMessage.value = "If the account exists, a password reset message has been issued.";
});
}
async function submitReset() {
await execute(async () => {
await resetPassword(resetForm);
returnedResetToken.value = "";
infoMessage.value = "Password updated. Sign in with the new password.";
pane.value = "login";
resetForm.token = "";
resetForm.newPassword = "";
});
}
function switchPane(nextPane: AuthPane) {
pane.value = nextPane;
errorMessage.value = "";
infoMessage.value = "";
}
async function execute(action: () => Promise<void>) {
errorMessage.value = "";
infoMessage.value = "";
authStore.setBusy(true);
try {
await action();
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Request failed.";
} finally {
authStore.setBusy(false);
}
}
</script>
<template>
<div class="auth-landing">
<div class="auth-landing__backdrop" />
<div class="auth-landing__hero">
<h1>Take command in a persistent universe.</h1>
<p>
Take your destiny into your own hands. Explore the frontier, forge alliances, expand your reach, and make enemies in a living space sim that keeps moving with or without you.
</p>
<div class="auth-card">
<div class="auth-card__tabs">
<button type="button" :class="{ 'is-active': pane === 'login' }" @click="switchPane('login')">Login</button>
<button type="button" :class="{ 'is-active': pane === 'register' }" @click="switchPane('register')">Register</button>
<button type="button" :class="{ 'is-active': pane === 'forgot' }" @click="switchPane('forgot')">Forgot</button>
</div>
<h2>{{ paneTitle }}</h2>
<form v-if="pane === 'login'" class="auth-card__form" @submit.prevent="submitLogin">
<input v-model.trim="loginForm.email" type="text" autocomplete="username" placeholder="Email or login">
<input v-model="loginForm.password" type="password" autocomplete="current-password" placeholder="Password">
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Signing in..." : "Sign in" }}</button>
<button type="button" class="auth-card__link" @click="switchPane('forgot')">Forgot password?</button>
</form>
<form v-else-if="pane === 'register'" class="auth-card__form" @submit.prevent="submitRegister">
<input v-model.trim="registerForm.email" type="email" autocomplete="email" placeholder="Email">
<input v-model="registerForm.password" type="password" autocomplete="new-password" placeholder="Password">
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Creating..." : "Create account" }}</button>
</form>
<form v-else-if="pane === 'forgot'" class="auth-card__form" @submit.prevent="submitForgot">
<input v-model.trim="forgotForm.email" type="email" autocomplete="email" placeholder="Email">
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Submitting..." : "Send reset link" }}</button>
</form>
<form v-else class="auth-card__form" @submit.prevent="submitReset">
<input v-model.trim="resetForm.token" type="text" autocomplete="off" placeholder="Reset token">
<input v-model="resetForm.newPassword" type="password" autocomplete="new-password" placeholder="New password">
<button type="submit" :disabled="authStore.busy">{{ authStore.busy ? "Updating..." : "Reset password" }}</button>
<button type="button" class="auth-card__link" @click="switchPane('login')">Back to login</button>
</form>
<div v-if="returnedResetToken" class="auth-card__token">
<div class="auth-card__token-label">Development reset token</div>
<code>{{ returnedResetToken }}</code>
</div>
<div v-if="infoMessage" class="auth-card__message auth-card__message--info">
{{ infoMessage }}
</div>
<div v-if="errorMessage" class="auth-card__message auth-card__message--error">
{{ errorMessage }}
</div>
<div class="auth-card__footer">
<button
v-if="pane !== 'reset'"
type="button"
class="auth-card__link"
@click="switchPane('reset')"
>
Have a reset token?
</button>
</div>
</div>
<div class="auth-landing__notes">
<div>Local account auth is active on this dev machine.</div>
<div>Google, Microsoft, and other providers can plug into the same identity model later.</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,138 @@
<script setup lang="ts">
import { reactive, ref } from "vue";
import { storeToRefs } from "pinia";
import { login, register } from "../api";
import { useAuthStore } from "../ui/stores/authStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
const authStore = useAuthStore();
const playerFactionStore = usePlayerFactionStore();
const { session, busy } = storeToRefs(authStore);
const mode = ref<"login" | "register">("login");
const email = ref("");
const password = ref("");
const errorMessage = ref("");
const forgotPasswordOpen = ref(false);
const forgotPasswordState = reactive({
email: "",
});
async function submit() {
errorMessage.value = "";
authStore.setBusy(true);
try {
const snapshot = mode.value === "login"
? await login({ email: email.value, password: password.value })
: await register({ email: email.value, password: password.value });
authStore.setSession(snapshot);
playerFactionStore.setPlayerFaction(null);
password.value = "";
forgotPasswordOpen.value = false;
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : "Authentication failed.";
} finally {
authStore.setBusy(false);
}
}
function logout() {
authStore.clearSession();
playerFactionStore.setPlayerFaction(null);
errorMessage.value = "";
password.value = "";
}
</script>
<template>
<div class="pointer-events-auto rounded-2xl border border-white/10 bg-[rgba(11,14,20,0.9)] px-4 py-4 text-[color:var(--viewer-text)] shadow-[0_18px_60px_rgba(0,0,0,0.35)] backdrop-blur">
<template v-if="session">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Identity</div>
<div class="mt-1 text-sm font-semibold">{{ session.email }}</div>
<div class="mt-1 text-xs text-white/55">Player {{ session.userId.slice(0, 8) }}</div>
<div v-if="session.roles.length > 0" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="role in session.roles"
:key="role"
class="rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-[10px] uppercase tracking-[0.16em] text-white/65"
>
{{ role }}
</span>
</div>
</div>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs transition hover:bg-white/10"
@click="logout"
>
Sign out
</button>
</div>
</template>
<template v-else>
<div class="flex items-center justify-between gap-3">
<div>
<div class="text-xs uppercase tracking-[0.22em] text-white/45">Pilot Access</div>
<div class="mt-1 text-sm font-semibold">{{ mode === "login" ? "Sign in" : "Create account" }}</div>
</div>
<button
type="button"
class="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-xs transition hover:bg-white/10"
@click="mode = mode === 'login' ? 'register' : 'login'"
>
{{ mode === "login" ? "Register" : "Login" }}
</button>
</div>
<form class="mt-3 flex flex-col gap-2.5" @submit.prevent="submit">
<input
v-model.trim="email"
type="email"
autocomplete="email"
placeholder="Email"
class="rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition placeholder:text-white/35 focus:border-white/30"
>
<input
v-model="password"
type="password"
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
placeholder="Password"
class="rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition placeholder:text-white/35 focus:border-white/30"
>
<button
type="submit"
class="rounded-xl bg-[#ff7458] px-3 py-2 text-sm font-semibold text-black transition hover:bg-[#ff8c74] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="busy"
>
{{ busy ? "Working..." : mode === "login" ? "Sign in" : "Create account" }}
</button>
</form>
<button
type="button"
class="mt-2 text-xs text-white/55 underline-offset-4 transition hover:text-white/80 hover:underline"
@click="forgotPasswordOpen = !forgotPasswordOpen"
>
Forgot password?
</button>
<div v-if="forgotPasswordOpen" class="mt-2 rounded-xl border border-dashed border-white/10 bg-black/15 px-3 py-2 text-xs text-white/60">
<div class="font-medium text-white/72">Reset flow not implemented yet.</div>
<div class="mt-1">Backend support is still missing for forgot-password and reset-token delivery.</div>
<input
v-model.trim="forgotPasswordState.email"
type="email"
autocomplete="email"
placeholder="Email for future reset flow"
class="mt-2 w-full rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-sm outline-none transition placeholder:text-white/35 focus:border-white/30"
>
</div>
<div v-if="errorMessage" class="mt-2 rounded-xl border border-[#ff7458]/25 bg-[rgba(255,116,88,0.12)] px-3 py-2 text-xs text-[#ffd8cf]">
{{ errorMessage }}
</div>
</template>
</div>
</template>

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>

View File

@@ -0,0 +1,594 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import modulesData from "../../../../shared/data/modules.json";
import { enqueueShipOrder, removeShipOrder, updateShipDefaultBehavior } from "../api";
import {
formatShipAutomationSupportStatus,
getShipBehaviorLabel,
getShipBehaviorNotes,
getShipBehaviorSupportStatusLabel,
getShipOrderLabel,
getShipOrderNotes,
getShipOrderSupportStatusLabel,
} from "../shipAutomationPresentation";
import { useGmStore } from "../ui/stores/gmStore";
import { useAuthStore } from "../ui/stores/authStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
import { useShipAutomationCatalogStore } from "../ui/stores/shipAutomationCatalogStore";
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
import type { Selectable } from "../viewerTypes";
const props = defineProps<{
fallbackTitle: string;
fallbackHtml: string;
}>();
const emit = defineEmits<{
focus: [selection: Selectable, cameraMode?: "follow" | "tactical"];
}>();
const gmStore = useGmStore();
const authStore = useAuthStore();
const playerStore = usePlayerFactionStore();
const automationStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { playerFaction } = storeToRefs(playerStore);
const commonMiningItems = ["ore", "silicon", "ice", "methane", "hydrogen", "helium"];
const behaviorForm = reactive({
kind: "hold-position",
areaSystemId: "",
itemId: "ore",
});
const mineOrderForm = reactive({
systemId: "",
itemId: "ore",
});
const moveOrderSystemId = ref("");
const actionBusy = ref(false);
const actionStatus = ref("");
const actionError = ref("");
const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((module) => [module.id, module.name]),
);
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 formatAmount(value: number) {
const rounded = Math.round(value);
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
}
const selectedShip = computed(() => {
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
return null;
}
return gmStore.ships.find((ship) => ship.id === selectedEntityId.value) ?? null;
});
const selectedStation = computed(() => {
if (selectedEntityKind.value !== "station" || !selectedEntityId.value) {
return null;
}
return gmStore.stations.find((station) => station.id === selectedEntityId.value) ?? null;
});
const factionLabelById = computed(() =>
new Map(gmStore.factions.map((faction) => [faction.id, faction.label])),
);
const playerShipIds = computed(() =>
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
);
const canAccessGm = computed(() => authStore.canAccessGm);
const canDirectControlSelectedShip = computed(() =>
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
);
const directOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind !== "behavior") ?? [],
);
const behaviorOrders = computed(() =>
selectedShip.value?.orderQueue.filter((order) => order.sourceKind === "behavior") ?? [],
);
const editableBehaviorDefinitions = computed(() =>
(automationStore.catalog?.behaviors ?? [])
.filter((entry) => entry.supportStatus !== "InternalOnly"),
);
const selectedBehaviorStatus = computed(() =>
selectedShip.value ? getShipBehaviorSupportStatusLabel(selectedShip.value.defaultBehavior.kind) : null,
);
const selectedBehaviorNotes = computed(() =>
selectedShip.value ? getShipBehaviorNotes(selectedShip.value.defaultBehavior.kind) : null,
);
const formBehaviorStatus = computed(() =>
getShipBehaviorSupportStatusLabel(behaviorForm.kind),
);
const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind),
);
watch(selectedShip, (ship) => {
if (!ship) {
return;
}
behaviorForm.kind = ship.defaultBehavior.kind;
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "ore";
mineOrderForm.systemId = ship.systemId ?? "";
mineOrderForm.itemId = "ore";
moveOrderSystemId.value = ship.systemId ?? "";
actionStatus.value = "";
actionError.value = "";
}, { immediate: true });
function focusShip(cameraMode?: "follow" | "tactical") {
if (!selectedShip.value) {
return;
}
emit("focus", { kind: "ship", id: selectedShip.value.id }, cameraMode);
}
function focusStation() {
if (!selectedStation.value) {
return;
}
emit("focus", { kind: "station", id: selectedStation.value.id }, "tactical");
}
async function runShipAction(action: () => Promise<void>, successMessage: string) {
actionBusy.value = true;
actionError.value = "";
actionStatus.value = "";
try {
await action();
actionStatus.value = successMessage;
} catch (error) {
actionError.value = error instanceof Error ? error.message : "Ship action failed.";
} finally {
actionBusy.value = false;
}
}
async function saveBehavior() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await updateShipDefaultBehavior(selectedShip.value!.id, {
kind: behaviorForm.kind,
homeSystemId: selectedShip.value!.systemId,
homeStationId: null,
areaSystemId: behaviorForm.kind === "local-auto-mine"
? (behaviorForm.areaSystemId || selectedShip.value!.systemId || null)
: null,
targetEntityId: null,
itemId: behaviorForm.kind === "local-auto-mine"
? (behaviorForm.itemId.trim() || null)
: null,
preferredNodeId: null,
preferredConstructionSiteId: null,
preferredModuleId: null,
targetPosition: null,
waitSeconds: selectedShip.value!.defaultBehavior.waitSeconds,
radius: selectedShip.value!.defaultBehavior.radius,
maxSystemRange: selectedShip.value!.defaultBehavior.maxSystemRange,
knownStationsOnly: selectedShip.value!.defaultBehavior.knownStationsOnly,
patrolPoints: [],
repeatOrders: [],
});
gmStore.upsertShip(ship);
}, "Default behavior updated.");
}
async function queueHoldPositionOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "hold-position",
priority: 100,
interruptCurrentPlan: true,
label: "Hold position",
targetEntityId: null,
targetSystemId: null,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Hold position order queued.");
}
async function queueMoveOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
const targetSystemId = moveOrderSystemId.value.trim();
if (!targetSystemId) {
actionError.value = "Select a target system.";
actionStatus.value = "";
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "move",
priority: 90,
interruptCurrentPlan: true,
label: `Move to ${targetSystemId}`,
targetEntityId: null,
targetSystemId,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Move order queued.");
}
async function queueMineResourceOrder() {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
const targetSystemId = mineOrderForm.systemId.trim() || selectedShip.value.systemId;
const itemId = mineOrderForm.itemId.trim();
if (!targetSystemId) {
actionError.value = "Select a mining system.";
actionStatus.value = "";
return;
}
if (!itemId) {
actionError.value = "Select a ware to mine.";
actionStatus.value = "";
return;
}
await runShipAction(async () => {
const ship = await enqueueShipOrder(selectedShip.value!.id, {
kind: "mine-and-deliver",
priority: 95,
interruptCurrentPlan: true,
label: `Mine ${itemId} in ${targetSystemId}`,
targetEntityId: null,
targetSystemId,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
}, "Mine Resource order queued.");
}
async function removeOrder(orderId: string) {
if (!selectedShip.value || !canDirectControlSelectedShip.value) {
return;
}
await runShipAction(async () => {
const ship = await removeShipOrder(selectedShip.value!.id, orderId);
gmStore.upsertShip(ship);
}, "Order removed.");
}
async function clearOrders() {
if (!selectedShip.value || !canDirectControlSelectedShip.value || directOrders.value.length === 0) {
return;
}
await runShipAction(async () => {
let latestShip = selectedShip.value!;
for (const order of [...latestShip.orderQueue.filter((entry) => entry.sourceKind !== "behavior")]) {
latestShip = await removeShipOrder(latestShip.id, order.id);
}
gmStore.upsertShip(latestShip);
}, "Orders cleared.");
}
</script>
<template>
<section class="entity-inspector-panel pointer-events-auto">
<template v-if="selectedShip">
<header class="entity-inspector-panel__header">
<div>
<div class="entity-inspector-panel__kicker">Ship Inspector</div>
<h3>{{ selectedShip.name }}</h3>
<p>{{ factionLabelById.get(selectedShip.factionId) ?? selectedShip.factionId }} · {{ selectedShip.systemId }}</p>
</div>
<div class="entity-inspector-panel__actions">
<button type="button" class="entity-inspector-panel__action" @click="focusShip('tactical')">Focus</button>
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Follow</button>
</div>
</header>
<div class="entity-inspector-section">
<h4>Status</h4>
<div class="entity-inspector-grid">
<div><span>State</span><strong>{{ titleCase(selectedShip.state) }}</strong></div>
<div><span>Behavior</span><strong>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</strong></div>
<div><span>Control</span><strong>{{ selectedShip.controlSourceKind }}</strong></div>
<div><span>Assignment</span><strong>{{ selectedShip.assignment?.kind ?? "unassigned" }}</strong></div>
<div><span>Plan</span><strong>{{ selectedShip.activePlan ? `${selectedShip.activePlan.kind} · ${selectedShip.activePlan.status}` : "none" }}</strong></div>
<div><span>Failure</span><strong>{{ selectedShip.lastAccessFailureReason ?? "none" }}</strong></div>
</div>
</div>
<div class="entity-inspector-section">
<h4>Cargo</h4>
<div class="entity-inspector-grid">
<div><span>Used</span><strong>{{ formatAmount(selectedShip.inventory.reduce((sum, entry) => sum + entry.amount, 0)) }}</strong></div>
<div><span>Capacity</span><strong>{{ formatAmount(selectedShip.cargoCapacity) }}</strong></div>
<div><span>Travel</span><strong>{{ formatAmount(selectedShip.travelSpeed) }} {{ selectedShip.travelSpeedUnit }}</strong></div>
<div><span>Hull</span><strong>{{ formatAmount(selectedShip.health) }}</strong></div>
</div>
<ul v-if="selectedShip.inventory.length > 0" class="entity-inspector-list">
<li v-for="entry in selectedShip.inventory" :key="entry.itemId">
<span>{{ entry.itemId }}</span>
<strong>{{ formatAmount(entry.amount) }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No cargo.</div>
</div>
<div class="entity-inspector-section">
<h4>Behavior</h4>
<div v-if="selectedBehaviorStatus || selectedBehaviorNotes" class="entity-inspector-note">
{{ [selectedBehaviorStatus, selectedBehaviorNotes].filter(Boolean).join(" · ") }}
</div>
<div class="entity-inspector-grid">
<div><span>Area</span><strong>{{ selectedShip.defaultBehavior.areaSystemId ?? "none" }}</strong></div>
<div><span>Item</span><strong>{{ selectedShip.defaultBehavior.itemId ?? "none" }}</strong></div>
<div><span>Home Station</span><strong>{{ selectedShip.defaultBehavior.homeStationId ?? "none" }}</strong></div>
<div><span>Target</span><strong>{{ selectedShip.defaultBehavior.targetEntityId ?? "none" }}</strong></div>
<div><span>Range</span><strong>{{ selectedShip.defaultBehavior.maxSystemRange }}</strong></div>
<div><span>Known Only</span><strong>{{ selectedShip.defaultBehavior.knownStationsOnly ? "yes" : "no" }}</strong></div>
</div>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<label class="entity-inspector-field">
<span>Default Behavior</span>
<select v-model="behaviorForm.kind">
<option v-for="entry in editableBehaviorDefinitions" :key="entry.id" :value="entry.id">
{{ entry.label }} ({{ formatShipAutomationSupportStatus(entry.supportStatus) }})
</option>
</select>
</label>
<div v-if="formBehaviorStatus || formBehaviorNotes" class="entity-inspector-note">
{{ [formBehaviorStatus, formBehaviorNotes].filter(Boolean).join(" · ") }}
</div>
<label v-if="behaviorForm.kind === 'local-auto-mine'" class="entity-inspector-field">
<span>System</span>
<select v-model="behaviorForm.areaSystemId">
<option value="">Current system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label v-if="behaviorForm.kind === 'local-auto-mine'" class="entity-inspector-field">
<span>Item</span>
<select v-model="behaviorForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<div class="entity-inspector-actions-row">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="saveBehavior">Save Behavior</button>
</div>
</div>
<div v-else class="entity-inspector-note">
Direct behavior editing is only available for player-owned ships or GM users.
</div>
</div>
<div class="entity-inspector-section">
<h4>Orders</h4>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<div class="entity-inspector-actions-row">
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueHoldPositionOrder">Hold Position</button>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy || directOrders.length === 0" @click="clearOrders">Clear Orders</button>
</div>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Move To System</span>
<select v-model="moveOrderSystemId">
<option value="">Select system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMoveOrder">Queue Move</button>
</div>
<div class="entity-inspector-inline-form">
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Mine Resource System</span>
<select v-model="mineOrderForm.systemId">
<option value="">Current system</option>
<option v-for="system in gmStore.systems" :key="system.id" :value="system.id">{{ system.label }} ({{ system.id }})</option>
</select>
</label>
<label class="entity-inspector-field entity-inspector-field--grow">
<span>Ware</span>
<select v-model="mineOrderForm.itemId">
<option v-for="itemId in commonMiningItems" :key="itemId" :value="itemId">{{ itemId }}</option>
</select>
</label>
<button type="button" class="entity-inspector-panel__action" :disabled="actionBusy" @click="queueMineResourceOrder">Queue Mine</button>
</div>
</div>
<div v-if="actionStatus" class="entity-inspector-message entity-inspector-message--ok">{{ actionStatus }}</div>
<div v-if="actionError" class="entity-inspector-message entity-inspector-message--error">{{ actionError }}</div>
<ul v-if="directOrders.length > 0" class="entity-inspector-list">
<li v-for="order in directOrders" :key="order.id">
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
<div class="entity-inspector-order-actions">
<strong>{{ order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—" }}</strong>
<button
v-if="canDirectControlSelectedShip"
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</div>
</li>
</ul>
<div v-else class="entity-inspector-empty">No direct orders queued.</div>
<div class="entity-inspector-divider">
<span>Behavior: {{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
</div>
<ul v-if="behaviorOrders.length > 0" class="entity-inspector-list">
<li v-for="order in behaviorOrders" :key="order.id">
<span>{{ getShipOrderLabel(order.kind) }} · {{ order.status }}</span>
<strong>{{ [order.itemId ?? order.targetEntityId ?? order.targetSystemId ?? "—", getShipOrderSupportStatusLabel(order.kind), getShipOrderNotes(order.kind)].filter(Boolean).join(" · ") }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No behavior orders queued.</div>
</div>
<div class="entity-inspector-section">
<h4>Plan Steps</h4>
<ul v-if="selectedShip.activePlan" class="entity-inspector-plan">
<li v-for="step in selectedShip.activePlan.steps" :key="step.id">
<div class="entity-inspector-plan__step">
<span>{{ step.kind }} · {{ step.status }}</span>
<strong>{{ step.blockingReason ?? "ok" }}</strong>
</div>
<ul class="entity-inspector-subtasks">
<li v-for="subTask in step.subTasks" :key="subTask.id">
<span>{{ subTask.kind }} · {{ subTask.status }}</span>
<strong>{{ subTask.blockingReason ?? `${Math.round(subTask.progress * 100)}%` }}</strong>
</li>
</ul>
</li>
</ul>
<div v-else class="entity-inspector-empty">No active plan.</div>
</div>
</template>
<template v-else-if="selectedStation">
<header class="entity-inspector-panel__header">
<div>
<div class="entity-inspector-panel__kicker">Station Inspector</div>
<h3>{{ selectedStation.label }}</h3>
<p>{{ factionLabelById.get(selectedStation.factionId) ?? selectedStation.factionId }} · {{ selectedStation.systemId }}</p>
</div>
<div class="entity-inspector-panel__actions">
<button type="button" class="entity-inspector-panel__action" @click="focusStation">Focus</button>
</div>
</header>
<div class="entity-inspector-section">
<h4>Status</h4>
<div class="entity-inspector-grid">
<div><span>Category</span><strong>{{ titleCase(selectedStation.category) }}</strong></div>
<div><span>Objective</span><strong>{{ titleCase(selectedStation.objective) }}</strong></div>
<div><span>Docked</span><strong>{{ selectedStation.dockedShips }} / {{ selectedStation.dockingPads }}</strong></div>
<div><span>Population</span><strong>{{ formatAmount(selectedStation.population) }} / {{ formatAmount(selectedStation.populationCapacity) }}</strong></div>
<div><span>Workforce</span><strong>{{ formatAmount(selectedStation.workforceRequired) }}</strong></div>
<div><span>Efficiency</span><strong>{{ Math.round(selectedStation.workforceEffectiveRatio * 100) }}%</strong></div>
</div>
</div>
<div class="entity-inspector-section">
<h4>Modules</h4>
<ul v-if="selectedStation.installedModules.length > 0" class="entity-inspector-list">
<li v-for="moduleId in selectedStation.installedModules" :key="moduleId">
<span>{{ moduleNameById.get(moduleId) ?? moduleId }}</span>
<strong>{{ moduleId }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No modules installed.</div>
</div>
<div class="entity-inspector-section">
<h4>Storage</h4>
<ul v-if="selectedStation.inventory.length > 0" class="entity-inspector-list">
<li v-for="entry in selectedStation.inventory" :key="entry.itemId">
<span>{{ entry.itemId }}</span>
<strong>{{ formatAmount(entry.amount) }}</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No inventory.</div>
</div>
<div class="entity-inspector-section">
<h4>Production</h4>
<ul v-if="selectedStation.currentProcesses.length > 0" class="entity-inspector-list">
<li v-for="process in selectedStation.currentProcesses" :key="`${process.lane}-${process.label}`">
<span>{{ process.label }}</span>
<strong>{{ Math.round(process.progress * 100) }}% · {{ Math.ceil(process.timeRemainingSeconds) }}s</strong>
</li>
</ul>
<div v-else class="entity-inspector-empty">No active processes.</div>
</div>
</template>
<template v-else>
<header class="entity-inspector-panel__header">
<div>
<div class="entity-inspector-panel__kicker">Inspector</div>
<h3>{{ props.fallbackTitle }}</h3>
</div>
</header>
<div class="entity-inspector-panel__fallback" v-html="props.fallbackHtml" />
</template>
</section>
</template>

View File

@@ -0,0 +1,320 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { storeToRefs } from "pinia";
import { enqueueShipOrder } from "../api";
import { getShipOrderLabel, getShipOrderNotes, getShipOrderSupportStatusLabel } from "../shipAutomationPresentation";
import { useAuthStore } from "../ui/stores/authStore";
import { useGmStore } from "../ui/stores/gmStore";
import { usePlayerFactionStore } from "../ui/stores/playerFactionStore";
import { useShipAutomationCatalogStore } from "../ui/stores/shipAutomationCatalogStore";
import { useViewerOrderContextMenuStore } from "../ui/stores/viewerOrderContextMenu";
import { useViewerSelectionStore } from "../ui/stores/viewerSelection";
type MenuAction =
| "mine-resource"
| "fly-to-and-wait"
| "follow"
| "attack";
interface OrderMenuActionEntry {
key: MenuAction;
orderKind: string;
label: string;
detail?: string;
supportStatus?: string | null;
notes?: string | null;
}
const rootEl = ref<HTMLElement | null>(null);
const authStore = useAuthStore();
const gmStore = useGmStore();
const playerStore = usePlayerFactionStore();
const automationStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const orderMenuStore = useViewerOrderContextMenuStore();
const { playerFaction } = storeToRefs(playerStore);
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { isOpen, x, y, target } = storeToRefs(orderMenuStore);
const selectedShip = computed(() => {
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
return null;
}
return gmStore.ships.find((ship) => ship.id === selectedEntityId.value) ?? null;
});
const canControlSelectedShip = computed(() => {
if (!selectedShip.value) {
return false;
}
if (authStore.canAccessGm) {
return true;
}
if (!playerFaction.value) {
return false;
}
return playerFaction.value.assetRegistry.shipIds.includes(selectedShip.value.id);
});
function emptyActions(): OrderMenuActionEntry[] {
return [];
}
const actions = computed<OrderMenuActionEntry[]>(() => {
if (!target.value || !selectedShip.value || !canControlSelectedShip.value) {
return emptyActions();
}
switch (target.value.selection.kind) {
case "node":
return [{
key: "mine-resource",
orderKind: "mine-and-deliver",
label: getShipOrderLabel("mine-and-deliver"),
detail: `${target.value.itemId ?? "resource"} · ${target.value.systemId ?? selectedShip.value.systemId}`,
supportStatus: getShipOrderSupportStatusLabel("mine-and-deliver"),
notes: getShipOrderNotes("mine-and-deliver"),
}];
case "ship":
if (target.value.selection.id === selectedShip.value.id) {
return emptyActions();
}
return [
{
key: "follow",
orderKind: "follow-ship",
label: getShipOrderLabel("follow-ship"),
detail: target.value.label,
supportStatus: getShipOrderSupportStatusLabel("follow-ship"),
notes: getShipOrderNotes("follow-ship"),
},
{
key: "attack",
orderKind: "attack-target",
label: getShipOrderLabel("attack-target"),
detail: target.value.label,
supportStatus: getShipOrderSupportStatusLabel("attack-target"),
notes: getShipOrderNotes("attack-target"),
},
];
case "station":
case "celestial":
case "construction-site":
return [{
key: "fly-to-and-wait",
orderKind: "fly-and-wait",
label: getShipOrderLabel("fly-and-wait"),
detail: target.value.label,
supportStatus: getShipOrderSupportStatusLabel("fly-and-wait"),
notes: getShipOrderNotes("fly-and-wait"),
}];
case "system":
return emptyActions();
default:
return emptyActions();
}
});
const menuStyle = computed(() => ({
left: `${Math.min(x.value, window.innerWidth - 280)}px`,
top: `${Math.min(y.value, window.innerHeight - 240)}px`,
}));
watch([selectedEntityKind, selectedEntityId], () => {
if (isOpen.value && selectedEntityKind.value !== "ship") {
orderMenuStore.close();
}
});
function closeMenu() {
orderMenuStore.close();
}
async function runAction(action: MenuAction) {
if (!selectedShip.value || !target.value || !canControlSelectedShip.value) {
return;
}
if (action === "mine-resource") {
const itemId = target.value.itemId;
if (!itemId) {
return;
}
const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "mine-and-deliver",
priority: 100,
interruptCurrentPlan: true,
label: `Mine ${itemId} in ${target.value.systemId ?? selectedShip.value.systemId}`,
targetEntityId: null,
targetSystemId: target.value.systemId ?? selectedShip.value.systemId,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId,
nodeId: target.value.selection.kind === "node" ? target.value.selection.id : null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
closeMenu();
return;
}
if (action === "fly-to-and-wait") {
const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "fly-and-wait",
priority: 100,
interruptCurrentPlan: true,
label: `Fly to ${target.value.label}`,
targetEntityId: null,
targetSystemId: target.value.systemId ?? selectedShip.value.systemId,
targetPosition: target.value.targetPosition ?? null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 8,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
closeMenu();
return;
}
if (action === "follow") {
const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "follow-ship",
priority: 100,
interruptCurrentPlan: true,
label: `Follow ${target.value.label}`,
targetEntityId: target.value.selection.kind === "ship" ? target.value.selection.id : null,
targetSystemId: target.value.systemId ?? null,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 6,
radius: 22,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
closeMenu();
return;
}
if (action === "attack") {
const ship = await enqueueShipOrder(selectedShip.value.id, {
kind: "attack-target",
priority: 100,
interruptCurrentPlan: true,
label: `Attack ${target.value.label}`,
targetEntityId: target.value.selection.kind === "ship" ? target.value.selection.id : null,
targetSystemId: target.value.systemId ?? null,
targetPosition: null,
sourceStationId: null,
destinationStationId: null,
itemId: null,
nodeId: null,
constructionSiteId: null,
moduleId: null,
waitSeconds: 0,
radius: 0,
maxSystemRange: 0,
knownStationsOnly: false,
});
gmStore.upsertShip(ship);
closeMenu();
}
}
function onActionClick(action: { key: MenuAction }) {
void runAction(action.key);
}
function onWindowPointerDown(event: PointerEvent) {
if (!isOpen.value) {
return;
}
if (rootEl.value?.contains(event.target as Node)) {
return;
}
closeMenu();
}
function onWindowKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
closeMenu();
}
}
onMounted(() => {
void automationStore.load();
window.addEventListener("pointerdown", onWindowPointerDown, true);
window.addEventListener("keydown", onWindowKeyDown);
});
onBeforeUnmount(() => {
window.removeEventListener("pointerdown", onWindowPointerDown, true);
window.removeEventListener("keydown", onWindowKeyDown);
});
</script>
<template>
<section
v-if="isOpen && target"
ref="rootEl"
class="viewer-order-context-menu pointer-events-auto"
:style="menuStyle"
@pointerdown.stop
@contextmenu.prevent.stop
>
<header class="viewer-order-context-menu__header">
<div class="viewer-order-context-menu__kicker">Direct Order</div>
<h4>{{ target.label }}</h4>
<p v-if="selectedShip">Selected ship: {{ selectedShip.name }}</p>
</header>
<div v-if="!selectedShip" class="viewer-order-context-menu__empty">
Select a ship first.
</div>
<div v-else-if="!canControlSelectedShip" class="viewer-order-context-menu__empty">
Direct orders are only available for player-owned ships or GM users.
</div>
<div v-else-if="actions.length === 0" class="viewer-order-context-menu__empty">
No direct actions for this target yet.
</div>
<div v-else class="viewer-order-context-menu__actions">
<button
v-for="action in actions"
:key="action.key"
type="button"
class="viewer-order-context-menu__action"
@click="onActionClick(action)"
>
<span>{{ action.label }}</span>
<strong v-if="action.detail">{{ action.detail }}</strong>
<small v-if="action.supportStatus || action.notes">{{ [action.supportStatus, action.notes].filter(Boolean).join(" · ") }}</small>
</button>
</div>
</section>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, h, ref } from "vue";
import { computed, h, reactive, ref } from "vue";
import {
useVueTable,
getCoreRowModel,
@@ -15,6 +15,8 @@ import { storeToRefs } from "pinia";
import GmWindow from "./GmWindow.vue";
import GmPlayerFactionPanel from "./GmPlayerFactionPanel.vue";
import GmGeopoliticsPanel from "./GmGeopoliticsPanel.vue";
import { createFaction, spawnShip, spawnStation } from "../../api";
import { getShipBehaviorLabel, getShipOrderLabel } from "../../shipAutomationPresentation";
import { useGmStore } from "../../ui/stores/gmStore";
import { usePlayerFactionStore } from "../../ui/stores/playerFactionStore";
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
@@ -94,6 +96,28 @@ const factionColorMap = computed(() =>
new Map(gmStore.factions.map((f) => [f.id, f.color])),
);
const createFactionForm = reactive({
factionId: "terran",
});
const spawnShipForm = reactive({
factionId: "",
systemId: "",
});
const spawnStationForm = reactive({
factionId: "",
systemId: "",
objective: "refinery",
});
const gmActionBusy = ref(false);
const gmActionError = ref("");
const gmActionInfo = ref("");
const availableSystemIds = computed(() => gmStore.systems.map((system) => system.id));
const availableFactionIds = computed(() => gmStore.factions.map((faction) => faction.id));
function renderColorCell(color: string | null | undefined) {
const resolved = color && color !== "—" ? color : "#6b7280";
return h("div", { class: "flex items-center justify-center" }, [
@@ -233,7 +257,7 @@ function describeFactionDecision(faction: FactionSnapshot) {
return latestDecision ? `${titleCaseToken(latestDecision.kind)} · ${latestDecision.summary}` : "—";
}
function describeFactionFronts(faction: FactionSnapshot) {
function describeFactionTheaters(faction: FactionSnapshot) {
const activeTheaters = faction.strategicState.theaters.filter((theater) => theater.status === "active");
const defense = activeTheaters.filter((theater) => theater.kind.includes("defense")).length;
const offense = activeTheaters.filter((theater) => theater.kind.includes("offense")).length;
@@ -269,15 +293,15 @@ const shipRows = computed<ShipRow[]>(() =>
const currentSubTask = s.activeSubTasks[0];
return {
id: s.id,
label: s.label,
class: s.class,
label: s.name,
class: s.type,
factionColor: factionColorMap.value.get(s.factionId) ?? "—",
faction: factionMap.value.get(s.factionId) ?? s.factionId,
system: s.systemId,
state: titleCaseToken(s.state),
assignment: s.assignment ? titleCaseToken(s.assignment.kind) : "—",
behavior: titleCaseToken(s.defaultBehavior.kind),
orders: topOrder ? `${titleCaseToken(topOrder.kind)} · ${s.orderQueue.length}` : "—",
behavior: getShipBehaviorLabel(s.defaultBehavior.kind),
orders: topOrder ? `${getShipOrderLabel(topOrder.kind)} · ${s.orderQueue.length}` : "—",
plan: s.activePlan ? `${titleCaseToken(s.activePlan.kind)} · ${titleCaseToken(s.activePlan.status)}` : "—",
step: currentStep ? `${titleCaseToken(currentStep.kind)} · ${titleCaseToken(currentStep.status)}` : "—",
subtask: currentSubTask ? `${titleCaseToken(currentSubTask.kind)} ${Math.round(currentSubTask.progress * 100)}%` : "—",
@@ -459,7 +483,7 @@ const factionRows = computed<FactionRow[]>(() =>
color: f.color,
planCycle: f.strategicState.planCycle,
posture: `${titleCaseToken(f.doctrine.strategicPosture)} · ${titleCaseToken(f.doctrine.militaryPosture)} · ${titleCaseToken(f.doctrine.economicPosture)}`,
fronts: describeFactionFronts(f),
fronts: describeFactionTheaters(f),
leadCampaign: describeFactionStrategicState(f),
leadObjective: describeFactionLeadTask(f),
commitments: describeFactionCommitments(f),
@@ -487,7 +511,7 @@ const factionColumns = [
}),
factionColumnHelper.accessor("planCycle", { header: "Cycle" }),
factionColumnHelper.accessor("posture", { header: "Posture" }),
factionColumnHelper.accessor("fronts", { header: "Fronts" }),
factionColumnHelper.accessor("fronts", { header: "Theaters" }),
factionColumnHelper.accessor("leadCampaign", { header: "Lead Campaign" }),
factionColumnHelper.accessor("leadObjective", { header: "Lead Objective" }),
factionColumnHelper.accessor("commitments", { header: "Commitments" }),
@@ -630,6 +654,61 @@ function moveOrdersTooltip(e: MouseEvent) {
function hideOrdersTooltip() {
ordersTooltip.value.visible = false;
}
async function submitCreateFaction() {
gmActionBusy.value = true;
gmActionError.value = "";
gmActionInfo.value = "";
try {
const faction = await createFaction({ factionId: createFactionForm.factionId.trim() });
gmStore.upsertFaction(faction);
if (!spawnShipForm.factionId) {
spawnShipForm.factionId = faction.id;
}
gmActionInfo.value = `Faction ${faction.label} created.`;
} catch (error) {
gmActionError.value = error instanceof Error ? error.message : "Create faction failed.";
} finally {
gmActionBusy.value = false;
}
}
async function submitSpawnMiner() {
gmActionBusy.value = true;
gmActionError.value = "";
gmActionInfo.value = "";
try {
const ship = await spawnShip({
factionId: spawnShipForm.factionId,
systemId: spawnShipForm.systemId,
});
gmStore.upsertShip(ship);
gmActionInfo.value = `Ship ${ship.name} spawned in ${ship.systemId}.`;
} catch (error) {
gmActionError.value = error instanceof Error ? error.message : "Spawn ship failed.";
} finally {
gmActionBusy.value = false;
}
}
async function submitSpawnStation() {
gmActionBusy.value = true;
gmActionError.value = "";
gmActionInfo.value = "";
try {
const station = await spawnStation({
factionId: spawnStationForm.factionId,
systemId: spawnStationForm.systemId,
objective: spawnStationForm.objective,
});
gmStore.upsertStation(station);
gmActionInfo.value = `Station ${station.label} spawned in ${station.systemId}.`;
} catch (error) {
gmActionError.value = error instanceof Error ? error.message : "Spawn station failed.";
} finally {
gmActionBusy.value = false;
}
}
</script>
<template>
@@ -685,6 +764,111 @@ function hideOrdersTooltip() {
</span>
</div>
<div
v-if="activeTab === 'factions' || activeTab === 'ships' || activeTab === 'stations'"
class="flex shrink-0 flex-wrap items-end gap-3 border-b border-white/10 px-3 py-3"
>
<template v-if="activeTab === 'factions'">
<label class="flex min-w-[220px] flex-col gap-1 text-[11px] uppercase tracking-[0.16em] text-white/55">
Faction Id
<input
v-model.trim="createFactionForm.factionId"
class="rounded border border-white/10 bg-black/20 px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-white/30"
placeholder="terran"
type="text"
>
</label>
<button
type="button"
class="rounded bg-[#ff7458] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-black transition hover:bg-[#ff8c74] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="gmActionBusy || !createFactionForm.factionId.trim()"
@click="submitCreateFaction"
>
{{ gmActionBusy ? "Working..." : "Create faction" }}
</button>
</template>
<template v-else-if="activeTab === 'ships'">
<label class="flex min-w-[180px] flex-col gap-1 text-[11px] uppercase tracking-[0.16em] text-white/55">
Faction
<select
v-model="spawnShipForm.factionId"
class="rounded border border-white/10 bg-black/20 px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-white/30"
>
<option value="">Select faction</option>
<option v-for="factionId in availableFactionIds" :key="factionId" :value="factionId">{{ factionId }}</option>
</select>
</label>
<label class="flex min-w-[180px] flex-col gap-1 text-[11px] uppercase tracking-[0.16em] text-white/55">
System
<select
v-model="spawnShipForm.systemId"
class="rounded border border-white/10 bg-black/20 px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-white/30"
>
<option value="">Select system</option>
<option v-for="systemId in availableSystemIds" :key="systemId" :value="systemId">{{ systemId }}</option>
</select>
</label>
<button
type="button"
class="rounded bg-[#ffbf69] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-black transition hover:bg-[#ffd08c] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="gmActionBusy || !spawnShipForm.factionId || !spawnShipForm.systemId"
@click="submitSpawnMiner"
>
{{ gmActionBusy ? "Working..." : "Spawn first miner" }}
</button>
</template>
<template v-else>
<label class="flex min-w-[180px] flex-col gap-1 text-[11px] uppercase tracking-[0.16em] text-white/55">
Faction
<select
v-model="spawnStationForm.factionId"
class="rounded border border-white/10 bg-black/20 px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-white/30"
>
<option value="">Select faction</option>
<option v-for="factionId in availableFactionIds" :key="factionId" :value="factionId">{{ factionId }}</option>
</select>
</label>
<label class="flex min-w-[180px] flex-col gap-1 text-[11px] uppercase tracking-[0.16em] text-white/55">
System
<select
v-model="spawnStationForm.systemId"
class="rounded border border-white/10 bg-black/20 px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-white/30"
>
<option value="">Select system</option>
<option v-for="systemId in availableSystemIds" :key="systemId" :value="systemId">{{ systemId }}</option>
</select>
</label>
<label class="flex min-w-[180px] flex-col gap-1 text-[11px] uppercase tracking-[0.16em] text-white/55">
Objective
<select
v-model="spawnStationForm.objective"
class="rounded border border-white/10 bg-black/20 px-3 py-2 text-sm normal-case tracking-normal text-white outline-none focus:border-white/30"
>
<option value="refinery">refinery</option>
<option value="power">power</option>
<option value="hullparts">hullparts</option>
<option value="claytronics">claytronics</option>
</select>
</label>
<button
type="button"
class="rounded bg-[#8df0d2] px-3 py-2 text-xs font-semibold uppercase tracking-[0.16em] text-black transition hover:bg-[#a8f5dd] disabled:cursor-not-allowed disabled:opacity-50"
:disabled="gmActionBusy || !spawnStationForm.factionId || !spawnStationForm.systemId"
@click="submitSpawnStation"
>
{{ gmActionBusy ? "Working..." : "Spawn station" }}
</button>
</template>
<div v-if="gmActionError" class="text-xs text-[#ffd8cf]">
{{ gmActionError }}
</div>
<div v-else-if="gmActionInfo" class="text-xs text-[color:var(--viewer-accent)]">
{{ gmActionInfo }}
</div>
</div>
<!-- Ships table -->
<div
v-show="activeTab === 'player'"

View File

@@ -14,16 +14,22 @@ import {
upsertPlayerDirective,
upsertPlayerPolicy,
} from "../../api";
import { getShipBehaviorLabel, getShipOrderLabel } from "../../shipAutomationPresentation";
import type { PlayerFactionSnapshot } from "../../contractsPlayerFaction";
import { useGmStore } from "../../ui/stores/gmStore";
import { useAuthStore } from "../../ui/stores/authStore";
import { usePlayerFactionStore } from "../../ui/stores/playerFactionStore";
import { useShipAutomationCatalogStore } from "../../ui/stores/shipAutomationCatalogStore";
import { useViewerSelectionStore } from "../../ui/stores/viewerSelection";
const gmStore = useGmStore();
const authStore = useAuthStore();
const playerStore = usePlayerFactionStore();
const automationStore = useShipAutomationCatalogStore();
const selectionStore = useViewerSelectionStore();
const { selectedEntityId, selectedEntityKind } = storeToRefs(selectionStore);
const { playerFaction } = storeToRefs(playerStore);
const { session } = storeToRefs(authStore);
const statusMessage = ref("");
const errorMessage = ref("");
@@ -129,56 +135,22 @@ const selectedOrganizationSummary = computed(() => {
return lines;
});
const behaviorOptions = [
"idle",
"hold-position",
"follow-ship",
"patrol",
"police",
"protect-position",
"protect-ship",
"protect-station",
"local-auto-mine",
"advanced-auto-mine",
"expert-auto-mine",
"local-auto-trade",
"advanced-auto-trade",
"fill-shortages",
"find-build-tasks",
"revisit-known-stations",
"supply-fleet",
"dock-and-wait",
"fly-and-wait",
"fly-to-object",
"auto-salvage",
"repeat-orders",
];
const behaviorOptions = computed(() =>
(automationStore.catalog?.behaviors ?? [])
.filter((entry) => entry.supportStatus !== "InternalOnly")
.map((entry) => entry.id),
);
onMounted(async () => {
if (playerStore.playerFaction) {
return;
}
try {
setPlayerSnapshot(await fetchPlayerFaction());
} catch {
// The world snapshot path normally provides this; ignore here and let the parent lifecycle recover.
}
await automationStore.load();
await loadPlayerSnapshotIfNeeded();
});
const orderOptions = [
"move",
"dock-at-station",
"dock-and-wait",
"fly-and-wait",
"fly-to-object",
"follow-ship",
"trade-route",
"mine-and-deliver",
"build-at-site",
"attack-target",
"hold-position",
];
const orderOptions = computed(() =>
(automationStore.catalog?.orders ?? [])
.filter((entry) => entry.supportStatus !== "InternalOnly")
.map((entry) => entry.id),
);
const orgForm = reactive({
kind: "fleet",
@@ -278,7 +250,7 @@ const behaviorForm = reactive({
homeSystemId: "",
areaSystemId: "",
targetEntityId: "",
preferredItemId: "",
itemId: "",
preferredNodeId: "",
preferredConstructionSiteId: "",
preferredModuleId: "",
@@ -353,6 +325,15 @@ watch(player, (value) => {
}
}, { immediate: true });
watch(session, async (value) => {
if (!value) {
playerStore.setPlayerFaction(null);
return;
}
await loadPlayerSnapshotIfNeeded();
});
watch(selectedShip, (ship) => {
if (!ship) {
return;
@@ -362,7 +343,7 @@ watch(selectedShip, (ship) => {
behaviorForm.homeSystemId = ship.defaultBehavior.homeSystemId ?? ship.systemId;
behaviorForm.areaSystemId = ship.defaultBehavior.areaSystemId ?? ship.systemId;
behaviorForm.targetEntityId = ship.defaultBehavior.targetEntityId ?? "";
behaviorForm.preferredItemId = ship.defaultBehavior.preferredItemId ?? "";
behaviorForm.itemId = ship.defaultBehavior.itemId ?? "";
behaviorForm.preferredNodeId = ship.defaultBehavior.preferredNodeId ?? "";
behaviorForm.preferredConstructionSiteId = ship.defaultBehavior.preferredConstructionSiteId ?? "";
behaviorForm.preferredModuleId = ship.defaultBehavior.preferredModuleId ?? "";
@@ -426,6 +407,18 @@ function setPlayerSnapshot(snapshot: PlayerFactionSnapshot) {
playerStore.setPlayerFaction(snapshot);
}
async function loadPlayerSnapshotIfNeeded() {
if (!authStore.isAuthenticated || playerStore.playerFaction) {
return;
}
try {
setPlayerSnapshot(await fetchPlayerFaction());
} catch {
// Player domain may not exist yet for the current world; leave the panel empty.
}
}
async function runAction(action: () => Promise<void>, successMessage: string) {
busy.value = true;
errorMessage.value = "";
@@ -618,7 +611,7 @@ async function submitDirectBehavior() {
homeStationId: null,
areaSystemId: behaviorForm.areaSystemId || null,
targetEntityId: behaviorForm.targetEntityId || null,
preferredItemId: behaviorForm.preferredItemId || null,
itemId: behaviorForm.itemId || null,
preferredNodeId: behaviorForm.preferredNodeId || null,
preferredConstructionSiteId: behaviorForm.preferredConstructionSiteId || null,
preferredModuleId: behaviorForm.preferredModuleId || null,
@@ -690,14 +683,14 @@ async function submitDirectOrder() {
<h4>Selected Asset Control</h4>
<div v-if="selectedShip || selectedStation" class="player-card-list">
<div class="player-card">
<strong>{{ selectedShip?.label ?? selectedStation?.label }}</strong>
<strong>{{ selectedShip?.name ?? selectedStation?.label }}</strong>
<span>{{ selectedShip ? "Ship" : "Station" }} · {{ selectedShip?.systemId ?? selectedStation?.systemId }}</span>
<span v-if="selectedAssignment">Assignment {{ selectedAssignment.role }} · {{ selectedAssignment.directiveId ?? "no directive" }}</span>
<span v-else>No player assignment</span>
</div>
<div v-if="selectedShip" class="player-card">
<strong>Behavior</strong>
<span>{{ titleCase(selectedShip.defaultBehavior.kind) }}</span>
<span>{{ getShipBehaviorLabel(selectedShip.defaultBehavior.kind) }}</span>
<span>Orders {{ selectedShip.orderQueue.length }} · Plan {{ selectedShip.activePlan?.kind ?? "none" }}</span>
<span>Command {{ titleCase(selectedShip.controlSourceKind) }}<template v-if="selectedShip.controlReason"> · {{ selectedShip.controlReason }}</template></span>
<span v-if="selectedShip.lastReplanReason">Replan {{ selectedShip.lastReplanReason }}</span>
@@ -708,11 +701,11 @@ async function submitDirectOrder() {
<form v-if="selectedShip" class="player-form" @submit.prevent="submitDirectBehavior">
<h5>Direct Ship Behavior</h5>
<label><span>Behavior</span><select v-model="behaviorForm.kind"><option v-for="option in behaviorOptions" :key="option" :value="option">{{ option }}</option></select></label>
<label><span>Behavior</span><select v-model="behaviorForm.kind"><option v-for="option in behaviorOptions" :key="option" :value="option">{{ getShipBehaviorLabel(option) }}</option></select></label>
<label><span>Home System</span><input v-model="behaviorForm.homeSystemId" type="text"></label>
<label><span>Area System</span><input v-model="behaviorForm.areaSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="behaviorForm.targetEntityId" type="text"></label>
<label><span>Preferred Item</span><input v-model="behaviorForm.preferredItemId" type="text"></label>
<label><span>Item</span><input v-model="behaviorForm.itemId" type="text"></label>
<label><span>Preferred Node</span><input v-model="behaviorForm.preferredNodeId" type="text"></label>
<label><span>Construction Site</span><input v-model="behaviorForm.preferredConstructionSiteId" type="text"></label>
<label><span>Module</span><input v-model="behaviorForm.preferredModuleId" type="text"></label>
@@ -725,7 +718,7 @@ async function submitDirectOrder() {
<form v-if="selectedShip" class="player-form" @submit.prevent="submitDirectOrder">
<h5>Direct Ship Order</h5>
<label><span>Order</span><select v-model="orderForm.kind"><option v-for="option in orderOptions" :key="option" :value="option">{{ option }}</option></select></label>
<label><span>Order</span><select v-model="orderForm.kind"><option v-for="option in orderOptions" :key="option" :value="option">{{ getShipOrderLabel(option) }}</option></select></label>
<label><span>Label</span><input v-model="orderForm.label" type="text"></label>
<label><span>Target System</span><input v-model="orderForm.targetSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="orderForm.targetEntityId" type="text"></label>
@@ -793,7 +786,7 @@ async function submitDirectOrder() {
<form class="player-form" @submit.prevent="submitCoreAutomation">
<h5>Core Automation</h5>
<label><span>Label</span><input v-model="automationForm.label" type="text"></label>
<label><span>Behavior</span><select v-model="automationForm.behaviorKind"><option v-for="option in behaviorOptions" :key="option" :value="option">{{ option }}</option></select></label>
<label><span>Behavior</span><select v-model="automationForm.behaviorKind"><option v-for="option in behaviorOptions" :key="option" :value="option">{{ getShipBehaviorLabel(option) }}</option></select></label>
<label><span>Order Staging</span><input v-model="automationForm.stagingOrderKind" type="text"></label>
<label><span>Preferred Item</span><input v-model="automationForm.preferredItemId" type="text"></label>
<label><span>System Range</span><input v-model.number="automationForm.maxSystemRange" type="number" min="0" step="1"></label>
@@ -852,7 +845,7 @@ async function submitDirectOrder() {
<label><span>Kind</span><input v-model="directiveForm.kind" type="text"></label>
<label><span>Scope Kind</span><input v-model="directiveForm.scopeKind" type="text"></label>
<label><span>Scope Id</span><input v-model="directiveForm.scopeId" type="text" :placeholder="selectedEntityId ?? ''"></label>
<label><span>Behavior</span><select v-model="directiveForm.behaviorKind"><option v-for="option in behaviorOptions" :key="option" :value="option">{{ option }}</option></select></label>
<label><span>Behavior</span><select v-model="directiveForm.behaviorKind"><option v-for="option in behaviorOptions" :key="option" :value="option">{{ getShipBehaviorLabel(option) }}</option></select></label>
<label><span>Staging Order</span><input v-model="directiveForm.stagingOrderKind" type="text"></label>
<label><span>Target System</span><input v-model="directiveForm.targetSystemId" type="text"></label>
<label><span>Target Entity</span><input v-model="directiveForm.targetEntityId" type="text"></label>

View File

@@ -0,0 +1,14 @@
export interface AuthSessionResponse {
userId: string;
email: string;
roles: string[];
accessToken: string;
accessTokenExpiresAtUtc: string;
refreshToken: string;
refreshTokenExpiresAtUtc: string;
}
export interface ForgotPasswordResponse {
accepted: boolean;
resetToken?: string | null;
}

View File

@@ -0,0 +1,20 @@
export interface ShipBehaviorDefinitionSnapshot {
id: string;
label: string;
category: string;
supportStatus: string;
notes: string;
}
export interface ShipOrderDefinitionSnapshot {
id: string;
label: string;
category: string;
supportStatus: string;
notes: string;
}
export interface ShipAutomationCatalogSnapshot {
behaviors: ShipBehaviorDefinitionSnapshot[];
orders: ShipOrderDefinitionSnapshot[];
}

View File

@@ -11,6 +11,8 @@ export interface ShipSkillProfileSnapshot {
export interface ShipOrderSnapshot {
id: string;
kind: string;
sourceKind: string;
sourceId: string;
status: string;
priority: number;
interruptCurrentPlan: boolean;
@@ -56,7 +58,7 @@ export interface DefaultBehaviorSnapshot {
homeStationId?: string | null;
areaSystemId?: string | null;
targetEntityId?: string | null;
preferredItemId?: string | null;
itemId?: string | null;
preferredNodeId?: string | null;
preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null;
@@ -137,9 +139,9 @@ export interface ShipPlanSnapshot {
export interface ShipSnapshot {
id: string;
label: string;
kind: string;
class: string;
name: string;
purpose: string;
type: string;
systemId: string;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;

View File

@@ -21,7 +21,6 @@ import type {
PolicySetSnapshot,
MarketOrderSnapshot,
} from "./contractsEconomy";
import type { PlayerFactionSnapshot } from "./contractsPlayerFaction";
import type { GeopoliticalStateSnapshot } from "./contractsGeopolitics";
import type {
ShipDelta,
@@ -46,7 +45,6 @@ export interface WorldSnapshot {
policies: PolicySetSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
playerFaction?: PlayerFactionSnapshot | null;
geopolitics?: GeopoliticalStateSnapshot | null;
}
@@ -67,7 +65,6 @@ export interface WorldDelta {
policies: PolicySetDelta[];
ships: ShipDelta[];
factions: FactionDelta[];
playerFaction?: PlayerFactionSnapshot | null;
geopolitics?: GeopoliticalStateSnapshot | null;
scope?: ObserverScope | null;
}

View File

@@ -2,6 +2,7 @@ import "./styles/index.css";
import { createApp } from "vue";
import App from "./App.vue";
import { viewerPinia } from "./ui/stores/pinia";
import { useAuthStore } from "./ui/stores/authStore";
const root = document.querySelector<HTMLDivElement>("#app");
@@ -12,3 +13,5 @@ if (!root) {
createApp(App)
.use(viewerPinia)
.mount(root);
useAuthStore(viewerPinia).initialize();

View File

@@ -0,0 +1,59 @@
import type {
ShipBehaviorDefinitionSnapshot,
ShipOrderDefinitionSnapshot,
} from "./contractsShipAutomation";
import { viewerPinia } from "./ui/stores/pinia";
import { useShipAutomationCatalogStore } from "./ui/stores/shipAutomationCatalogStore";
function titleCaseAutomationId(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/[-_]+/g, " ")
.replace(/\s+/g, " ")
.trim()
.replace(/\b\w/g, (part) => part.toUpperCase());
}
function titleCaseStatus(value: string): string {
return value
.replace(/([a-z])([A-Z])/g, "$1 $2")
.trim();
}
export function formatShipAutomationSupportStatus(value: string | null | undefined): string | null {
return value ? titleCaseStatus(value) : null;
}
export function getShipBehaviorDefinition(id: string): ShipBehaviorDefinitionSnapshot | null {
return useShipAutomationCatalogStore(viewerPinia).behaviorMap.get(id) ?? null;
}
export function getShipOrderDefinition(id: string): ShipOrderDefinitionSnapshot | null {
return useShipAutomationCatalogStore(viewerPinia).orderMap.get(id) ?? null;
}
export function getShipBehaviorLabel(id: string): string {
return getShipBehaviorDefinition(id)?.label ?? titleCaseAutomationId(id);
}
export function getShipOrderLabel(id: string): string {
return getShipOrderDefinition(id)?.label ?? titleCaseAutomationId(id);
}
export function getShipBehaviorSupportStatusLabel(id: string): string | null {
const status = getShipBehaviorDefinition(id)?.supportStatus;
return formatShipAutomationSupportStatus(status);
}
export function getShipOrderSupportStatusLabel(id: string): string | null {
const status = getShipOrderDefinition(id)?.supportStatus;
return formatShipAutomationSupportStatus(status);
}
export function getShipBehaviorNotes(id: string): string | null {
return getShipBehaviorDefinition(id)?.notes ?? null;
}
export function getShipOrderNotes(id: string): string | null {
return getShipOrderDefinition(id)?.notes ?? null;
}

View File

@@ -27,7 +27,7 @@ export interface ShipDefaultBehaviorCommandRequest {
homeStationId?: string | null;
areaSystemId?: string | null;
targetEntityId?: string | null;
preferredItemId?: string | null;
itemId?: string | null;
preferredNodeId?: string | null;
preferredConstructionSiteId?: string | null;
preferredModuleId?: string | null;

View File

@@ -30,6 +30,243 @@ body {
color: var(--viewer-text);
}
input,
button,
textarea,
select {
font: inherit;
}
.auth-landing {
min-height: 100vh;
position: relative;
display: grid;
grid-template-columns: minmax(0, 680px);
justify-content: start;
padding: 48px;
align-items: center;
isolation: isolate;
overflow: hidden;
}
.auth-landing__backdrop {
position: absolute;
inset: 0;
z-index: -2;
background:
linear-gradient(90deg, rgba(3, 7, 14, 0.9) 0%, rgba(3, 7, 14, 0.52) 42%, rgba(3, 7, 14, 0.84) 100%),
linear-gradient(180deg, rgba(2, 6, 10, 0.1), rgba(2, 6, 10, 0.72)),
url("../assets/backdrop1.webp");
background-position: center;
background-size: cover;
transform: scale(1.04);
}
.auth-landing__backdrop::after {
content: "";
position: absolute;
inset: 0;
background:
radial-gradient(circle at 22% 36%, rgba(127, 214, 255, 0.22), transparent 24%),
radial-gradient(circle at 74% 28%, rgba(255, 116, 88, 0.18), transparent 20%),
linear-gradient(180deg, rgba(2, 7, 13, 0.12) 0%, rgba(2, 7, 13, 0.48) 100%);
mix-blend-mode: screen;
}
.auth-landing__hero {
max-width: 680px;
display: grid;
gap: 18px;
}
.auth-landing__badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-radius: 999px;
border: 1px solid rgba(127, 214, 255, 0.2);
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-accent);
font-size: 0.76rem;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.auth-landing__hero h1 {
margin: 0;
font-size: clamp(2.4rem, 4vw, 4.4rem);
line-height: 0.95;
letter-spacing: -0.05em;
text-wrap: balance;
}
.auth-landing__hero p {
margin: 0;
max-width: 48ch;
color: var(--viewer-muted);
font-size: 1rem;
line-height: 1.7;
}
.auth-landing__notes {
display: grid;
gap: 10px;
color: rgba(234, 244, 255, 0.72);
font-size: 0.92rem;
}
.auth-card {
position: relative;
width: min(100%, 460px);
margin-top: 6px;
border-radius: 28px;
padding: 28px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.03)),
linear-gradient(180deg, rgba(8, 14, 24, 0.08), rgba(5, 10, 18, 0.14)),
radial-gradient(circle at top right, rgba(127, 214, 255, 0.12), transparent 42%);
border: 1px solid rgba(255, 255, 255, 0.18);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.18);
backdrop-filter: blur(22px) saturate(140%) brightness(1.06);
-webkit-backdrop-filter: blur(22px) saturate(140%) brightness(1.06);
}
.auth-card__tabs {
display: flex;
gap: 8px;
}
.auth-card__tabs button {
border: 0;
border-radius: 999px;
padding: 8px 14px;
background: rgba(255, 255, 255, 0.04);
color: rgba(234, 244, 255, 0.66);
cursor: pointer;
transition: background 140ms ease, color 140ms ease, transform 140ms ease;
}
.auth-card__tabs button.is-active,
.auth-card__tabs button:hover {
background: rgba(127, 214, 255, 0.14);
color: var(--viewer-text);
transform: translateY(-1px);
}
.auth-card h2 {
margin: 18px 0 16px;
font-size: 1.4rem;
letter-spacing: -0.03em;
}
.auth-card__form {
display: grid;
gap: 12px;
}
.auth-card__form input {
width: 100%;
border: 1px solid rgba(127, 214, 255, 0.12);
border-radius: 16px;
background: rgba(0, 0, 0, 0.18);
padding: 12px 14px;
color: var(--viewer-text);
outline: none;
}
.auth-card__form input:focus {
border-color: rgba(127, 214, 255, 0.34);
}
.auth-card__form button[type="submit"] {
border: 0;
border-radius: 16px;
padding: 12px 14px;
background: linear-gradient(90deg, #ff7458, #ffbf69);
color: #111;
font-weight: 700;
cursor: pointer;
}
.auth-card__form button[disabled] {
opacity: 0.56;
cursor: not-allowed;
}
.auth-card__link {
justify-self: start;
border: 0;
background: transparent;
color: rgba(234, 244, 255, 0.68);
padding: 0;
cursor: pointer;
}
.auth-card__token {
margin-top: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px dashed rgba(127, 214, 255, 0.22);
background: rgba(255, 255, 255, 0.03);
}
.auth-card__token-label {
margin-bottom: 6px;
font-size: 0.76rem;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--viewer-accent);
}
.auth-card__token code {
display: block;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
word-break: break-all;
}
.auth-card__message {
margin-top: 14px;
border-radius: 16px;
padding: 12px 14px;
font-size: 0.92rem;
}
.auth-card__message--info {
background: rgba(127, 214, 255, 0.1);
border: 1px solid rgba(127, 214, 255, 0.18);
color: #d5f2ff;
}
.auth-card__message--error {
background: rgba(255, 116, 88, 0.12);
border: 1px solid rgba(255, 116, 88, 0.22);
color: #ffd7cf;
}
.auth-card__footer {
margin-top: 14px;
}
@media (max-width: 980px) {
.auth-landing {
padding: 28px;
overflow: auto;
}
}
@media (max-width: 640px) {
.auth-landing {
padding: 18px;
}
.auth-card {
padding: 22px;
}
}
canvas {
display: block;
}
@@ -874,3 +1111,495 @@ canvas {
opacity: 0.45;
cursor: default;
}
.entity-browser-panel,
.entity-inspector-panel {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid var(--viewer-panel-border);
background: var(--viewer-panel);
backdrop-filter: blur(20px);
border-radius: 1.5rem;
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35);
color: var(--viewer-text);
padding: 1rem;
}
.entity-browser-panel__header,
.entity-inspector-panel__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.entity-browser-panel__header h3,
.entity-inspector-panel__header h3 {
margin: 0.2rem 0 0;
font-size: 1rem;
font-weight: 600;
}
.entity-browser-panel__header p,
.entity-inspector-panel__header p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: var(--viewer-muted);
}
.entity-browser-panel__kicker,
.entity-inspector-panel__kicker {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(173, 220, 255, 0.72);
}
.entity-browser-panel__context {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.2rem;
font-size: 0.72rem;
color: var(--viewer-muted);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.entity-browser-panel__tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
margin-top: 0.9rem;
}
.entity-browser-panel__tab,
.entity-inspector-panel__action,
.entity-browser-item__focus,
.entity-browser-item__body {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
transition: background 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.entity-browser-panel__tab,
.entity-inspector-panel__action,
.entity-browser-item__focus {
border-radius: 999px;
}
.entity-browser-panel__tab {
padding: 0.65rem 0.9rem;
font-size: 0.8rem;
}
.entity-browser-panel__tab:hover,
.entity-inspector-panel__action:hover,
.entity-browser-item__focus:hover,
.entity-browser-item__body:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.entity-browser-panel__tab--active {
background: rgba(116, 196, 255, 0.14);
border-color: rgba(116, 196, 255, 0.32);
}
.entity-browser-panel__search {
width: 100%;
margin-top: 0.8rem;
border-radius: 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
color: var(--viewer-text);
padding: 0.75rem 0.9rem;
outline: none;
}
.entity-browser-panel__search:focus {
border-color: rgba(173, 220, 255, 0.4);
}
.entity-browser-panel__empty,
.entity-inspector-empty {
margin-top: 0.9rem;
color: var(--viewer-muted);
font-size: 0.84rem;
}
.entity-browser-panel__sections {
margin-top: 0.9rem;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.9rem;
min-height: 0;
overflow: auto;
padding-right: 0.2rem;
}
.entity-browser-section {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.entity-browser-section__header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(173, 220, 255, 0.64);
}
.entity-browser-section__items {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.entity-browser-item {
display: flex;
align-items: stretch;
gap: 0.55rem;
}
.entity-browser-item__body {
flex: 1 1 auto;
text-align: left;
padding: 0.75rem 0.85rem;
border-radius: 1rem;
}
.entity-browser-item__body:disabled {
opacity: 0.82;
cursor: default;
}
.entity-browser-item--selected .entity-browser-item__body {
border-color: rgba(116, 196, 255, 0.38);
background: rgba(116, 196, 255, 0.12);
}
.entity-browser-item__label {
font-size: 0.88rem;
font-weight: 600;
}
.entity-browser-item__subtitle,
.entity-browser-item__meta {
margin-top: 0.18rem;
font-size: 0.75rem;
color: var(--viewer-muted);
}
.entity-browser-item__focus,
.entity-inspector-panel__action {
padding: 0.65rem 0.9rem;
font-size: 0.78rem;
align-self: center;
}
.entity-inspector-panel__actions {
display: flex;
gap: 0.45rem;
}
.entity-inspector-panel {
overflow: auto;
}
.entity-inspector-section {
margin-top: 1rem;
padding-top: 0.95rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.entity-inspector-section h4 {
margin: 0 0 0.65rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(173, 220, 255, 0.7);
}
.entity-inspector-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.7rem 0.9rem;
}
.entity-inspector-grid span {
display: block;
font-size: 0.72rem;
color: var(--viewer-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.entity-inspector-grid strong {
display: block;
margin-top: 0.15rem;
font-size: 0.86rem;
font-weight: 600;
}
.entity-inspector-list,
.entity-inspector-plan,
.entity-inspector-subtasks {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.entity-inspector-list li,
.entity-inspector-plan__step,
.entity-inspector-subtasks li {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: baseline;
padding: 0.55rem 0.7rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.035);
}
.entity-inspector-list span,
.entity-inspector-plan__step span,
.entity-inspector-subtasks span {
font-size: 0.8rem;
}
.entity-inspector-list strong,
.entity-inspector-plan__step strong,
.entity-inspector-subtasks strong {
font-size: 0.75rem;
color: var(--viewer-muted);
}
.entity-inspector-plan > li {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.entity-inspector-subtasks {
padding-left: 0.8rem;
}
.entity-inspector-panel__fallback {
margin-top: 0.9rem;
font-size: 0.83rem;
line-height: 1.55;
color: var(--viewer-muted);
}
.entity-inspector-panel__fallback p {
margin: 0 0 0.7rem;
}
.entity-inspector-form {
margin-top: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.7rem;
}
.entity-inspector-inline-form,
.entity-inspector-actions-row,
.entity-inspector-order-actions {
display: flex;
gap: 0.6rem;
align-items: end;
}
.entity-inspector-order-actions {
align-items: center;
}
.entity-inspector-field {
display: flex;
flex-direction: column;
gap: 0.28rem;
min-width: 0;
}
.entity-inspector-field--grow {
flex: 1 1 auto;
}
.entity-inspector-field span {
font-size: 0.72rem;
color: var(--viewer-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.entity-inspector-field select,
.entity-inspector-field input {
min-width: 0;
border-radius: 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
color: var(--viewer-text);
padding: 0.72rem 0.85rem;
outline: none;
}
.entity-inspector-field select:focus,
.entity-inspector-field input:focus {
border-color: rgba(173, 220, 255, 0.4);
}
.entity-inspector-note {
margin-top: 0.9rem;
color: var(--viewer-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.entity-inspector-message {
margin-top: 0.8rem;
border-radius: 0.9rem;
padding: 0.72rem 0.85rem;
font-size: 0.78rem;
}
.entity-inspector-message--ok {
border: 1px solid rgba(52, 211, 153, 0.22);
background: rgba(52, 211, 153, 0.1);
color: #d5fff1;
}
.entity-inspector-message--error {
border: 1px solid rgba(255, 116, 88, 0.22);
background: rgba(255, 116, 88, 0.1);
color: #ffd8cf;
}
.entity-inspector-order-remove {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
border-radius: 999px;
padding: 0.42rem 0.72rem;
font-size: 0.72rem;
transition: background 120ms ease, border-color 120ms ease;
}
.entity-inspector-order-remove:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.22);
}
.entity-inspector-divider {
margin-top: 0.95rem;
padding-top: 0.85rem;
border-top: 1px dashed rgba(173, 220, 255, 0.22);
}
.entity-inspector-divider span {
display: inline-block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: rgba(173, 220, 255, 0.72);
}
.viewer-order-context-menu {
position: fixed;
z-index: 80;
width: min(260px, calc(100vw - 24px));
border: 1px solid var(--viewer-panel-border);
background: rgba(8, 12, 20, 0.94);
backdrop-filter: blur(18px);
border-radius: 1.15rem;
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.45);
color: var(--viewer-text);
padding: 0.9rem;
}
.viewer-order-context-menu__header h4 {
margin: 0.2rem 0 0;
font-size: 0.94rem;
font-weight: 600;
}
.viewer-order-context-menu__header p {
margin: 0.25rem 0 0;
color: var(--viewer-muted);
font-size: 0.75rem;
}
.viewer-order-context-menu__kicker {
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: rgba(173, 220, 255, 0.72);
}
.viewer-order-context-menu__actions {
margin-top: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.viewer-order-context-menu__action {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.8rem;
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: var(--viewer-text);
border-radius: 0.95rem;
padding: 0.75rem 0.85rem;
text-align: left;
transition: background 120ms ease, border-color 120ms ease;
}
.viewer-order-context-menu__action:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.18);
}
.viewer-order-context-menu__action span {
font-size: 0.82rem;
font-weight: 600;
}
.viewer-order-context-menu__action strong {
color: var(--viewer-muted);
font-size: 0.72rem;
font-weight: 500;
}
.viewer-order-context-menu__empty {
margin-top: 0.8rem;
color: var(--viewer-muted);
font-size: 0.8rem;
line-height: 1.45;
}
@media (max-width: 760px) {
.entity-inspector-grid {
grid-template-columns: minmax(0, 1fr);
}
.entity-inspector-inline-form,
.entity-inspector-actions-row,
.entity-inspector-order-actions {
flex-direction: column;
align-items: stretch;
}
}

View File

@@ -0,0 +1,41 @@
import { defineStore } from "pinia";
import type { AuthSessionResponse } from "../../contractsAuth";
import { clearAuthSession, getAuthSession, setAuthSession, subscribeToAuthSession } from "../../authSession";
export const useAuthStore = defineStore("auth", {
state: () => ({
session: getAuthSession() as AuthSessionResponse | null,
busy: false,
initialized: false,
}),
getters: {
isAuthenticated: (state) => state.session != null,
email: (state) => state.session?.email ?? null,
roles: (state) => state.session?.roles ?? [],
canAccessGm: (state) => (state.session?.roles ?? []).some((role) => role === "gm" || role === "admin"),
accessToken: (state) => state.session?.accessToken ?? null,
},
actions: {
setSession(session: AuthSessionResponse | null) {
this.session = session;
setAuthSession(session);
},
clearSession() {
this.session = null;
clearAuthSession();
},
setBusy(busy: boolean) {
this.busy = busy;
},
initialize() {
if (this.initialized) {
return;
}
this.initialized = true;
subscribeToAuthSession((session) => {
this.session = session as AuthSessionResponse | null;
});
},
},
});

View File

@@ -4,9 +4,11 @@ import type { StationSnapshot } from "../../contractsInfrastructure";
import type { FactionSnapshot } from "../../contractsFactions";
import type { MarketOrderSnapshot } from "../../contractsEconomy";
import type { GeopoliticalStateSnapshot } from "../../contractsGeopolitics";
import type { SystemSnapshot } from "../../contractsCelestial";
export const useGmStore = defineStore("gm", {
state: () => ({
systems: [] as SystemSnapshot[],
ships: [] as ShipSnapshot[],
stations: [] as StationSnapshot[],
factions: [] as FactionSnapshot[],
@@ -15,12 +17,14 @@ export const useGmStore = defineStore("gm", {
}),
actions: {
updateWorld(
systems: SystemSnapshot[],
ships: ShipSnapshot[],
stations: StationSnapshot[],
factions: FactionSnapshot[],
marketOrders: MarketOrderSnapshot[],
geopolitics: GeopoliticalStateSnapshot | null,
) {
this.systems = systems;
this.ships = ships;
this.stations = stations;
this.factions = factions;
@@ -35,5 +39,21 @@ export const useGmStore = defineStore("gm", {
}
this.ships.push(ship);
},
upsertFaction(faction: FactionSnapshot) {
const index = this.factions.findIndex((candidate) => candidate.id === faction.id);
if (index >= 0) {
this.factions.splice(index, 1, faction);
return;
}
this.factions.push(faction);
},
upsertStation(station: StationSnapshot) {
const index = this.stations.findIndex((candidate) => candidate.id === station.id);
if (index >= 0) {
this.stations.splice(index, 1, station);
return;
}
this.stations.push(station);
},
},
});

View File

@@ -0,0 +1,48 @@
import { defineStore } from "pinia";
import { fetchShipAutomationCatalog } from "../../api";
import type { ShipAutomationCatalogSnapshot } from "../../contractsShipAutomation";
let loadPromise: Promise<void> | null = null;
export const useShipAutomationCatalogStore = defineStore("shipAutomationCatalog", {
state: () => ({
catalog: null as ShipAutomationCatalogSnapshot | null,
loading: false,
loaded: false,
error: "" as string,
}),
getters: {
behaviorMap: (state) =>
new Map((state.catalog?.behaviors ?? []).map((entry) => [entry.id, entry])),
orderMap: (state) =>
new Map((state.catalog?.orders ?? []).map((entry) => [entry.id, entry])),
},
actions: {
async load(force = false) {
if (this.loaded && !force) {
return;
}
if (loadPromise && !force) {
return loadPromise;
}
this.loading = true;
this.error = "";
loadPromise = (async () => {
try {
this.catalog = await fetchShipAutomationCatalog();
this.loaded = true;
} catch (error) {
this.error = error instanceof Error ? error.message : "Failed to load ship automation catalog.";
throw error;
} finally {
this.loading = false;
loadPromise = null;
}
})();
return loadPromise;
},
},
});

View File

@@ -0,0 +1,32 @@
import { defineStore } from "pinia";
import type { Vector3Dto } from "../../contractsCommon";
import type { Selectable } from "../../viewerTypes";
export interface ViewerOrderContextMenuTarget {
selection: Selectable;
label: string;
systemId?: string | null;
itemId?: string | null;
targetPosition?: Vector3Dto | null;
}
export const useViewerOrderContextMenuStore = defineStore("viewerOrderContextMenu", {
state: () => ({
isOpen: false,
x: 0,
y: 0,
target: null as ViewerOrderContextMenuTarget | null,
}),
actions: {
open(x: number, y: number, target: ViewerOrderContextMenuTarget) {
this.isOpen = true;
this.x = x;
this.y = y;
this.target = target;
},
close() {
this.isOpen = false;
this.target = null;
},
},
});

View File

@@ -0,0 +1,19 @@
import { defineStore } from "pinia";
import type { PovLevel } from "../../viewerTypes";
export const useViewerSceneStore = defineStore("viewerScene", {
state: () => ({
activeSystemId: null as string | null,
povLevel: "system" as PovLevel,
}),
actions: {
setViewContext(activeSystemId: string | null, povLevel: PovLevel) {
this.activeSystemId = activeSystemId;
this.povLevel = povLevel;
},
reset() {
this.activeSystemId = null;
this.povLevel = "system";
},
},
});

View File

@@ -5,8 +5,13 @@ import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import { useViewerSceneStore } from "./ui/stores/viewerScene";
import { useViewerOrderContextMenuStore } from "./ui/stores/viewerOrderContextMenu";
import { viewerPinia } from "./ui/stores/pinia";
export function createViewerControllers(host: any) {
const sceneStore = useViewerSceneStore(viewerPinia);
const orderContextMenuStore = useViewerOrderContextMenuStore(viewerPinia);
const sceneDataController = new ViewerSceneDataController({
documentRef: document,
getWorldOrbitalTimeSeconds: () => host.world?.orbitalTimeSeconds,
@@ -41,6 +46,7 @@ export function createViewerControllers(host: any) {
getActiveSystemId: () => host.activeSystemId,
setActiveSystemId: (value) => {
host.activeSystemId = value;
sceneStore.setViewContext(value ?? null, host.povLevel);
},
onActiveSystemChanged: (oldId, newId) => {
sceneDataController.onActiveSystemChanged(oldId, newId);
@@ -243,6 +249,8 @@ export function createViewerControllers(host: any) {
updatePanels: () => host.updatePanels(),
focusOnSelection: (selection) => navigationController.focusOnSelection(selection),
updateGamePanel: (mode) => host.updateGamePanel(mode),
openOrderContextMenu: (x, y, target) => orderContextMenuStore.open(x, y, target),
closeOrderContextMenu: () => orderContextMenuStore.close(),
historyController,
});
@@ -263,6 +271,7 @@ export function wireViewerEvents(host: any) {
canvas.addEventListener("pointerup", host.interactionController.onPointerUp);
canvas.addEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.addEventListener("click", host.interactionController.onClick);
canvas.addEventListener("contextmenu", host.interactionController.onContextMenu);
canvas.addEventListener("dblclick", host.interactionController.onDoubleClick);
canvas.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
@@ -277,6 +286,7 @@ export function wireViewerEvents(host: any) {
canvas.removeEventListener("pointerup", host.interactionController.onPointerUp);
canvas.removeEventListener("pointerleave", host.interactionController.onPointerUp);
canvas.removeEventListener("click", host.interactionController.onClick);
canvas.removeEventListener("contextmenu", host.interactionController.onContextMenu);
canvas.removeEventListener("dblclick", host.interactionController.onDoubleClick);
canvas.removeEventListener("wheel", host.interactionController.onWheel);
host.historyLayerEl.removeEventListener("click", host.interactionController.onHistoryLayerClick);

View File

@@ -32,7 +32,7 @@ export function refreshHistoryWindow(
return false;
}
windowState.title = `${ship.label} History`;
windowState.title = `${ship.name} History`;
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
windowState.bodyHtml = windowState.text.replaceAll("\n", "<br>");
return true;

View File

@@ -22,6 +22,7 @@ import type {
WorldState,
PovLevel,
} from "./viewerTypes";
import type { ViewerOrderContextMenuTarget } from "./ui/stores/viewerOrderContextMenu";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
@@ -65,6 +66,8 @@ export interface ViewerInteractionContext {
updatePanels: () => void;
focusOnSelection: (selection: Selectable) => void;
updateGamePanel: (mode: string) => void;
openOrderContextMenu: (x: number, y: number, target: ViewerOrderContextMenuTarget) => void;
closeOrderContextMenu: () => void;
historyController: ViewerHistoryWindowController;
}
@@ -143,6 +146,7 @@ export class ViewerInteractionController {
};
readonly onClick = (event: MouseEvent) => {
this.context.closeOrderContextMenu();
if (this.context.getSuppressClickSelection()) {
this.context.setSuppressClickSelection(false);
return;
@@ -157,6 +161,23 @@ export class ViewerInteractionController {
this.context.updatePanels();
};
readonly onContextMenu = (event: MouseEvent) => {
event.preventDefault();
this.context.closeOrderContextMenu();
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
if (!picked) {
return;
}
const target = this.buildOrderContextTarget(picked);
if (!target) {
return;
}
this.context.openOrderContextMenu(event.clientX, event.clientY, target);
};
readonly onOpsStripClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
@@ -370,4 +391,69 @@ export class ViewerInteractionController {
return selection.kind === "system" && selection.id !== this.context.getActiveSystemId();
}
private buildOrderContextTarget(selection: Selectable): ViewerOrderContextMenuTarget | null {
const world = this.context.getWorld();
if (!world) {
return null;
}
switch (selection.kind) {
case "ship": {
const ship = world.ships.get(selection.id);
return ship ? {
selection,
label: ship.name,
systemId: ship.systemId,
targetPosition: ship.localPosition,
} : null;
}
case "station": {
const station = world.stations.get(selection.id);
return station ? {
selection,
label: station.label,
systemId: station.systemId,
targetPosition: station.localPosition,
} : null;
}
case "node": {
const node = world.nodes.get(selection.id);
return node ? {
selection,
label: node.itemId,
systemId: node.systemId,
itemId: node.itemId,
targetPosition: node.localPosition,
} : null;
}
case "celestial": {
const celestial = world.celestials.get(selection.id);
return celestial ? {
selection,
label: selection.id,
systemId: celestial.systemId,
targetPosition: celestial.orbitalAnchor,
} : null;
}
case "construction-site": {
const site = world.constructionSites.get(selection.id);
return site ? {
selection,
label: site.blueprintId ?? site.targetDefinitionId ?? site.id,
systemId: site.systemId,
} : null;
}
case "system": {
const system = world.systems.get(selection.id);
return system ? {
selection,
label: system.label,
systemId: system.id,
} : null;
}
default:
return null;
}
}
}

View File

@@ -8,10 +8,13 @@ import type {
OpsStripState,
} from "./viewerHudState";
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
import type { CameraMode, Selectable, WorldState, PovLevel } from "./viewerTypes";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
function buildFactionCard(world: WorldState, faction: FactionSnapshot): OpsFactionCardState {
const playerFaction = world.playerFaction;
const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
if (playerFaction && playerFaction.sovereignFactionId === faction.id) {
const selectedDirective = playerFaction.directives[0];
return {
@@ -106,8 +109,8 @@ function buildShipCard(
return {
kind: "ship",
id: ship.id,
label: ship.label,
badge: ship.class,
label: ship.name,
badge: ship.type,
selected: isSelected,
followed: isFollowed,
locationLines: [shipLocation.system, ...(shipLocation.local ? [shipLocation.local] : [])],
@@ -118,9 +121,9 @@ function buildShipCard(
action: shipAction ? buildProgressBar(shipAction.label, shipAction.progress) : undefined,
aiLines: [
`Assignment ${ship.assignment?.kind ?? "unassigned"}`,
`Behavior ${ship.defaultBehavior.kind}`,
`Behavior ${getShipBehaviorLabel(ship.defaultBehavior.kind)}`,
`Plan ${ship.activePlan ? `${ship.activePlan.kind}${currentStep ? ` · ${currentStep.kind}` : ""}` : "none"}`,
`Orders ${topOrder ? `${topOrder.kind} +${Math.max(0, ship.orderQueue.length - 1)}` : "none"}`,
`Orders ${topOrder ? `${getShipOrderLabel(topOrder.kind)} +${Math.max(0, ship.orderQueue.length - 1)}` : "none"}`,
],
};
}
@@ -157,7 +160,7 @@ export function buildOpsStripState(
const ships = [...world.ships.values()]
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
.sort((left, right) => left.label.localeCompare(right.label))
.sort((left, right) => left.name.localeCompare(right.name))
.map((ship) => buildShipCard(
world,
ship,

View File

@@ -5,6 +5,9 @@ import {
formatSystemDistance,
inventoryAmount,
} from "./viewerMath";
import { usePlayerFactionStore } from "./ui/stores/playerFactionStore";
import { viewerPinia } from "./ui/stores/pinia";
import { getShipOrderLabel } from "./shipAutomationPresentation";
import modulesData from "../../../shared/data/modules.json";
import itemsData from "../../../shared/data/items.json";
@@ -297,17 +300,18 @@ export function buildDetailPanelState(params: DetailPanelParams) {
const shipAction = describeShipCurrentAction(ship);
const currentStep = ship.activePlan?.steps[ship.activePlan.currentStepIndex];
const orderQueue = ship.orderQueue.length > 0
? ship.orderQueue.slice(0, 4).map((order) => `${order.kind} [${order.status}]`).join("<br>")
? ship.orderQueue.slice(0, 4).map((order) => `${getShipOrderLabel(order.kind)} [${order.status}]`).join("<br>")
: "none";
const subTaskList = ship.activeSubTasks.length > 0
? ship.activeSubTasks.slice(0, 4).map((subTask) => `${subTask.summary || subTask.kind} · ${subTask.status}`).join("<br>")
: "none";
const playerAssignment = world.playerFaction?.assignments.find((assignment) => assignment.assetKind === "ship" && assignment.assetId === ship.id);
const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
const playerAssignment = playerFaction?.assignments.find((assignment) => assignment.assetKind === "ship" && assignment.assetId === ship.id);
const playerDirective = playerAssignment?.directiveId
? world.playerFaction?.directives.find((directive) => directive.id === playerAssignment.directiveId)
? playerFaction?.directives.find((directive) => directive.id === playerAssignment.directiveId)
: undefined;
return {
title: ship.label,
title: ship.name,
bodyHtml: `
<p>Parent ${parent}</p>
<p>Behavior ${shipBehavior}</p>
@@ -348,12 +352,13 @@ export function buildDetailPanelState(params: DetailPanelParams) {
const parent = describeSelectionParent(selected);
const moduleList = formatModuleListWithConstruction(world, station.id, station.installedModules, station.currentProcesses);
const dockedShipLabels = station.dockedShipIds.length > 0
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.name ?? shipId).join("<br>")
: "none";
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
const playerAssignment = world.playerFaction?.assignments.find((assignment) => assignment.assetKind === "station" && assignment.assetId === station.id);
const playerFaction = usePlayerFactionStore(viewerPinia).playerFaction;
const playerAssignment = playerFaction?.assignments.find((assignment) => assignment.assetKind === "station" && assignment.assetId === station.id);
const playerDirective = playerAssignment?.directiveId
? world.playerFaction?.directives.find((directive) => directive.id === playerAssignment.directiveId)
? playerFaction?.directives.find((directive) => directive.id === playerAssignment.directiveId)
: undefined;
return {
title: station.label,

View File

@@ -2,14 +2,19 @@ import * as THREE from "three";
import type { ShipSnapshot } from "./contracts";
export function shipSize(ship: ShipSnapshot) {
switch (ship.class) {
case "capital":
switch (ship.type) {
case "carrier":
return 0.018;
case "cruiser":
case "battleship":
return 0.012;
case "destroyer":
return 0.009;
case "industrial":
case "builder":
case "freighter":
case "transporter":
case "resupplier":
case "miner":
case "largeminer":
return 0.01;
default:
return 0.007;
@@ -20,11 +25,11 @@ export function shipLength(ship: ShipSnapshot) {
return shipSize(ship) * 2.6;
}
export function shipColor(kind: ShipSnapshot["kind"]) {
if (kind === "mining") {
export function shipColor(purpose: ShipSnapshot["purpose"]) {
if (purpose === "mine") {
return "#ffcf6e";
}
if (kind === "transport") {
if (purpose === "trade") {
return "#9ff0aa";
}
return "#8bc0ff";
@@ -40,7 +45,7 @@ export function shipPresentationColor(ship: ShipSnapshot) {
if (ship.spatialState.movementRegime === "ftl-transit") {
return "#ff6ad5";
}
return shipColor(ship.kind);
return shipColor(ship.purpose);
}
export function celestialColor(kind: string) {

View File

@@ -7,13 +7,14 @@ import type {
WorldState,
} from "./viewerTypes";
import { formatGalaxyDistance } from "./viewerMath";
import { getShipBehaviorLabel, getShipOrderLabel } from "./shipAutomationPresentation";
export function describeSelectable(world: WorldState | undefined, item: Selectable): string {
if (!world) {
return item.kind;
}
if (item.kind === "ship") {
return world.ships.get(item.id)?.label ?? item.id;
return world.ships.get(item.id)?.name ?? item.id;
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
@@ -52,7 +53,7 @@ export function describeHoverLabel(world: WorldState | undefined, item: Selectab
}
const lines = [
ship.label,
ship.name,
`Behavior ${describeShipBehavior(ship)}`,
`State ${describeShipState(world, ship)}`,
`Order ${describeShipOrder(ship)}`,
@@ -303,7 +304,7 @@ export function renderSystemDetails(
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
? `<p>Camera locked to ${world.ships.get(cameraTargetShipId)?.label ?? cameraTargetShipId}</p>`
? `<p>Camera locked to ${world.ships.get(cameraTargetShipId)?.name ?? cameraTargetShipId}</p>`
: "";
return `
@@ -353,7 +354,7 @@ export function describeShipObjective(objective: string): string {
}
export function describeShipBehavior(ship: ShipSnapshot): string {
const parts = [ship.defaultBehavior.kind];
const parts = [getShipBehaviorLabel(ship.defaultBehavior.kind)];
if (ship.assignment?.kind) {
parts.push(ship.assignment.kind);
}
@@ -364,7 +365,7 @@ export function describeShipOrder(ship: ShipSnapshot): string {
const activeOrder = [...ship.orderQueue]
.sort((left, right) => right.priority - left.priority || left.createdAtUtc.localeCompare(right.createdAtUtc))[0];
if (activeOrder) {
return activeOrder.label ?? activeOrder.kind;
return activeOrder.label ?? getShipOrderLabel(activeOrder.kind);
}
if (ship.assignment?.kind) {
@@ -375,7 +376,7 @@ export function describeShipOrder(ship: ShipSnapshot): string {
return ship.activePlan.summary || ship.activePlan.kind;
}
return ship.defaultBehavior.kind;
return getShipBehaviorLabel(ship.defaultBehavior.kind);
}
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {

View File

@@ -49,7 +49,6 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
policies: new Map(snapshot.policies.map((policy) => [policy.id, policy])),
ships: new Map(snapshot.ships.map((ship) => [ship.id, ship])),
factions: new Map(snapshot.factions.map((faction) => [faction.id, faction])),
playerFaction: snapshot.playerFaction ?? null,
geopolitics: snapshot.geopolitics ?? null,
recentEvents: [],
};
@@ -90,14 +89,11 @@ export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean
for (const faction of delta.factions) {
world.factions.set(faction.id, faction);
}
if (delta.playerFaction !== undefined) {
world.playerFaction = delta.playerFaction ?? null;
}
if (delta.geopolitics !== undefined) {
world.geopolitics = delta.geopolitics ?? null;
}
return delta.factions.length > 0 || delta.playerFaction !== undefined || delta.geopolitics !== undefined;
return delta.factions.length > 0 || delta.geopolitics !== undefined;
}
export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta, rawBytes: number): void {
@@ -110,7 +106,7 @@ export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta,
+ delta.marketOrders.length
+ delta.policies.length
+ delta.factions.length;
const changedEntitiesWithPlayer = changedEntities + (delta.playerFaction ? 1 : 0) + (delta.geopolitics ? 1 : 0);
const changedEntitiesWithPlayer = changedEntities + (delta.geopolitics ? 1 : 0);
networkStats.deltasReceived += 1;
networkStats.deltaBytes += rawBytes;
networkStats.lastDeltaBytes = rawBytes;

View File

@@ -14,7 +14,6 @@ import type {
StationSnapshot,
SystemSnapshot,
OrbitalSimulationSnapshot,
PlayerFactionSnapshot,
GeopoliticalStateSnapshot,
} from "./contracts";
@@ -154,7 +153,6 @@ export interface WorldState {
policies: Map<string, PolicySetSnapshot>;
ships: Map<string, ShipSnapshot>;
factions: Map<string, FactionSnapshot>;
playerFaction: PlayerFactionSnapshot | null;
geopolitics: GeopoliticalStateSnapshot | null;
recentEvents: SimulationEventRecord[];
}

View File

@@ -152,6 +152,7 @@ export class ViewerWorldLifecycle {
}
applySnapshot(snapshot: WorldSnapshot) {
usePlayerFactionStore(viewerPinia).setPlayerFaction(null);
this.context.setWorldTimeSyncMs(performance.now());
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.context.getWorldSignature()) {
@@ -202,13 +203,13 @@ export class ViewerWorldLifecycle {
const world = this.context.getWorld();
if (world) {
useGmStore(viewerPinia).updateWorld(
[...world.systems.values()],
[...world.ships.values()],
[...world.stations.values()],
[...world.factions.values()],
[...world.marketOrders.values()],
world.geopolitics,
);
usePlayerFactionStore(viewerPinia).setPlayerFaction(world.playerFaction);
}
}