Refactor runtime bootstrap and ship control flows
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
BIN
apps/viewer/src/assets/backdrop1.webp
Normal file
BIN
apps/viewer/src/assets/backdrop1.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 341 KiB |
76
apps/viewer/src/authSession.ts
Normal file
76
apps/viewer/src/authSession.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
185
apps/viewer/src/components/AuthLandingPage.vue
Normal file
185
apps/viewer/src/components/AuthLandingPage.vue
Normal 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>
|
||||
138
apps/viewer/src/components/AuthSessionPanel.vue
Normal file
138
apps/viewer/src/components/AuthSessionPanel.vue
Normal 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>
|
||||
322
apps/viewer/src/components/ViewerEntityBrowserPanel.vue
Normal file
322
apps/viewer/src/components/ViewerEntityBrowserPanel.vue
Normal 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>
|
||||
594
apps/viewer/src/components/ViewerEntityInspectorPanel.vue
Normal file
594
apps/viewer/src/components/ViewerEntityInspectorPanel.vue
Normal 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>
|
||||
320
apps/viewer/src/components/ViewerShipOrderContextMenu.vue
Normal file
320
apps/viewer/src/components/ViewerShipOrderContextMenu.vue
Normal 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>
|
||||
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
|
||||
14
apps/viewer/src/contractsAuth.ts
Normal file
14
apps/viewer/src/contractsAuth.ts
Normal 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;
|
||||
}
|
||||
20
apps/viewer/src/contractsShipAutomation.ts
Normal file
20
apps/viewer/src/contractsShipAutomation.ts
Normal 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[];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
59
apps/viewer/src/shipAutomationPresentation.ts
Normal file
59
apps/viewer/src/shipAutomationPresentation.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
41
apps/viewer/src/ui/stores/authStore.ts
Normal file
41
apps/viewer/src/ui/stores/authStore.ts
Normal 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;
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
48
apps/viewer/src/ui/stores/shipAutomationCatalogStore.ts
Normal file
48
apps/viewer/src/ui/stores/shipAutomationCatalogStore.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
32
apps/viewer/src/ui/stores/viewerOrderContextMenu.ts
Normal file
32
apps/viewer/src/ui/stores/viewerOrderContextMenu.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
19
apps/viewer/src/ui/stores/viewerScene.ts
Normal file
19
apps/viewer/src/ui/stores/viewerScene.ts
Normal 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";
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user