Add player onboarding and tactical viewer updates

This commit is contained in:
2026-04-06 17:12:44 -04:00
parent 706e1cda8f
commit 63a9f808bb
52 changed files with 2699 additions and 577 deletions

View File

@@ -76,6 +76,50 @@ function formatAmount(value: number) {
return Math.abs(value - rounded) < 0.005 ? String(rounded) : value.toFixed(1);
}
function formatPercent(value: number) {
return `${Math.round(value * 100)}%`;
}
function joinDetail(parts: Array<string | null | undefined>) {
return parts.filter((part): part is string => !!part && part.trim().length > 0).join(" · ");
}
function describeOrderTarget(order: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
nodeId?: string | null;
constructionSiteId?: string | null;
sourceStationId?: string | null;
destinationStationId?: string | null;
moduleId?: string | null;
}) {
return order.itemId
?? order.targetEntityId
?? order.targetSystemId
?? order.nodeId
?? order.constructionSiteId
?? order.destinationStationId
?? order.sourceStationId
?? order.moduleId
?? "—";
}
function describeSubTaskTarget(subTask: {
itemId?: string | null;
targetEntityId?: string | null;
targetSystemId?: string | null;
targetNodeId?: string | null;
moduleId?: string | null;
}) {
return subTask.itemId
?? subTask.targetEntityId
?? subTask.targetSystemId
?? subTask.targetNodeId
?? subTask.moduleId
?? "—";
}
const selectedShip = computed(() => {
if (selectedEntityKind.value !== "ship" || !selectedEntityId.value) {
return null;
@@ -100,10 +144,10 @@ const playerShipIds = computed(() =>
new Set(playerFaction.value?.assetRegistry.shipIds ?? []),
);
const canAccessGm = computed(() => authStore.canAccessGm);
const canAccessGmDirectly = computed(() => authStore.canAccessGm && !authStore.isActingAsAlternateIdentity);
const canDirectControlSelectedShip = computed(() =>
!!selectedShip.value && (canAccessGm.value || playerShipIds.value.has(selectedShip.value.id)),
!!selectedShip.value && (canAccessGmDirectly.value || playerShipIds.value.has(selectedShip.value.id)),
);
const directOrders = computed(() =>
@@ -135,20 +179,208 @@ const formBehaviorNotes = computed(() =>
getShipBehaviorNotes(behaviorForm.kind),
);
watch(selectedShip, (ship) => {
if (!ship) {
return;
const shipStatusRows = computed(() => {
if (!selectedShip.value) {
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 });
return [
{ label: "State", value: titleCase(selectedShip.value.state) },
{ label: "Behavior", value: getShipBehaviorLabel(selectedShip.value.defaultBehavior.kind) },
{ label: "Control", value: titleCase(selectedShip.value.controlSourceKind) },
{ label: "Assignment", value: selectedShip.value.assignment?.kind ?? "unassigned" },
{
label: "Plan",
value: selectedShip.value.activePlan
? `${selectedShip.value.activePlan.kind} · ${titleCase(selectedShip.value.activePlan.status)}`
: "none",
},
{ label: "Failure", value: selectedShip.value.lastAccessFailureReason ?? "none" },
{ label: "Commander", value: selectedShip.value.commanderId ?? "none" },
{ label: "Docked", value: selectedShip.value.dockedStationId ?? "no" },
];
});
const shipCargoSummaryRows = computed(() => {
if (!selectedShip.value) {
return [];
}
const usedCargo = selectedShip.value.inventory.reduce((sum, entry) => sum + entry.amount, 0);
return [
{ label: "Used", value: formatAmount(usedCargo) },
{ label: "Capacity", value: formatAmount(selectedShip.value.cargoCapacity) },
{ label: "Free", value: formatAmount(Math.max(selectedShip.value.cargoCapacity - usedCargo, 0)) },
{ label: "Travel", value: `${formatAmount(selectedShip.value.travelSpeed)} ${selectedShip.value.travelSpeedUnit}` },
{ label: "Hull", value: formatAmount(selectedShip.value.health) },
{ label: "Regime", value: titleCase(selectedShip.value.spatialState.movementRegime) },
];
});
const shipCargoRows = computed(() =>
selectedShip.value?.inventory.map((entry) => ({
key: entry.itemId,
ware: entry.itemId,
amount: formatAmount(entry.amount),
})) ?? [],
);
const shipBehaviorRows = computed(() => {
if (!selectedShip.value) {
return [];
}
return [
{ label: "Area", value: selectedShip.value.defaultBehavior.areaSystemId ?? "none" },
{ label: "Item", value: selectedShip.value.defaultBehavior.itemId ?? "none" },
{ label: "Home Station", value: selectedShip.value.defaultBehavior.homeStationId ?? "none" },
{ label: "Target", value: selectedShip.value.defaultBehavior.targetEntityId ?? "none" },
{ label: "Range", value: String(selectedShip.value.defaultBehavior.maxSystemRange) },
{ label: "Known Only", value: selectedShip.value.defaultBehavior.knownStationsOnly ? "yes" : "no" },
];
});
const directOrderRows = computed(() =>
directOrders.value.map((order) => ({
id: order.id,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
detail: joinDetail([
`P${order.priority}`,
titleCase(order.sourceKind),
order.failureReason ?? undefined,
]),
})),
);
const behaviorOrderRows = computed(() =>
behaviorOrders.value.map((order) => ({
id: order.id,
label: getShipOrderLabel(order.kind),
status: titleCase(order.status),
target: describeOrderTarget(order),
detail: joinDetail([
`P${order.priority}`,
getShipOrderSupportStatusLabel(order.kind) ?? undefined,
getShipOrderNotes(order.kind) ?? undefined,
order.failureReason ?? undefined,
]),
})),
);
const shipPlanRows = computed(() => {
if (!selectedShip.value?.activePlan) {
return [];
}
return selectedShip.value.activePlan.steps.flatMap((step) => {
const stepRow = {
id: step.id,
scope: "Step",
activity: step.summary || titleCase(step.kind),
status: titleCase(step.status),
detail: joinDetail([
step.blockingReason ?? undefined,
`${step.subTasks.length} subtasks`,
]),
isSubTask: false,
};
const subTaskRows = step.subTasks.map((subTask) => ({
id: subTask.id,
scope: "Subtask",
activity: subTask.summary || titleCase(subTask.kind),
status: titleCase(subTask.status),
detail: joinDetail([
describeSubTaskTarget(subTask),
subTask.blockingReason ?? undefined,
`${Math.round(subTask.progress * 100)}%`,
]),
isSubTask: true,
}));
return [stepRow, ...subTaskRows];
});
});
const stationStatusRows = computed(() => {
if (!selectedStation.value) {
return [];
}
return [
{ label: "Category", value: titleCase(selectedStation.value.category) },
{ label: "Objective", value: titleCase(selectedStation.value.objective) },
{ label: "Docked", value: `${selectedStation.value.dockedShips} / ${selectedStation.value.dockingPads}` },
{
label: "Population",
value: `${formatAmount(selectedStation.value.population)} / ${formatAmount(selectedStation.value.populationCapacity)}`,
},
{ label: "Workforce", value: formatAmount(selectedStation.value.workforceRequired) },
{ label: "Efficiency", value: formatPercent(selectedStation.value.workforceEffectiveRatio) },
{ label: "Commander", value: selectedStation.value.commanderId ?? "none" },
{ label: "Policy", value: selectedStation.value.policySetId ?? "none" },
];
});
const stationModuleRows = computed(() =>
selectedStation.value?.installedModules.map((moduleId) => ({
key: moduleId,
module: moduleNameById.get(moduleId) ?? moduleId,
moduleId,
})) ?? [],
);
const stationStorageRows = computed(() =>
selectedStation.value?.storageUsage.map((entry) => ({
key: entry.storageClass,
storageClass: titleCase(entry.storageClass),
used: formatAmount(entry.used),
capacity: formatAmount(entry.capacity),
fill: entry.capacity > 0 ? formatPercent(entry.used / entry.capacity) : "0%",
})) ?? [],
);
const stationInventoryRows = computed(() =>
selectedStation.value?.inventory.map((entry) => ({
key: entry.itemId,
ware: entry.itemId,
amount: formatAmount(entry.amount),
})) ?? [],
);
const stationProcessRows = computed(() =>
selectedStation.value?.currentProcesses.map((process) => ({
key: `${process.lane}-${process.label}`,
lane: process.lane,
label: process.label,
progress: formatPercent(process.progress),
timing: `${Math.ceil(process.timeRemainingSeconds)}s / ${Math.ceil(process.cycleSeconds)}s`,
})) ?? [],
);
watch(
() => `${selectedEntityKind.value ?? "none"}:${selectedEntityId.value ?? "none"}`,
() => {
const ship = selectedShip.value;
if (!ship) {
actionStatus.value = "";
actionError.value = "";
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) {
@@ -357,36 +589,52 @@ async function clearOrders() {
</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>
<button type="button" class="entity-inspector-panel__action" @click="focusShip('follow')">Track</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 class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipStatusRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</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 class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipCargoSummaryRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="shipCargoRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Ware</th>
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipCargoRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
</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>
@@ -395,13 +643,15 @@ async function clearOrders() {
<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 class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in shipBehaviorRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="canDirectControlSelectedShip" class="entity-inspector-form">
<label class="entity-inspector-field">
@@ -473,52 +723,86 @@ async function clearOrders() {
</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-if="directOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
<th v-if="canDirectControlSelectedShip" scope="col" class="entity-inspector-table__action-col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="order in directOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
<td v-if="canDirectControlSelectedShip" class="entity-inspector-table__action-col">
<button
type="button"
class="entity-inspector-order-remove"
:disabled="actionBusy"
@click="removeOrder(order.id)"
>
Remove
</button>
</td>
</tr>
</tbody>
</table>
</div>
<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-if="behaviorOrderRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Order</th>
<th scope="col">Status</th>
<th scope="col">Target</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="order in behaviorOrderRows" :key="order.id">
<td>{{ order.label }}</td>
<td>{{ order.status }}</td>
<td>{{ order.target }}</td>
<td class="entity-inspector-table__detail">{{ order.detail }}</td>
</tr>
</tbody>
</table>
</div>
<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-if="shipPlanRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Scope</th>
<th scope="col">Activity</th>
<th scope="col">Status</th>
<th scope="col">Detail</th>
</tr>
</thead>
<tbody>
<tr v-for="row in shipPlanRows" :key="row.id" :class="row.isSubTask ? 'entity-inspector-table__row--subtask' : ''">
<td>{{ row.scope }}</td>
<td :class="row.isSubTask ? 'entity-inspector-table__subtask' : ''">{{ row.activity }}</td>
<td>{{ row.status }}</td>
<td class="entity-inspector-table__detail">{{ row.detail }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active plan.</div>
</div>
</template>
@@ -537,46 +821,102 @@ async function clearOrders() {
<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 class="entity-inspector-table-wrap">
<table class="entity-inspector-table entity-inspector-table--kv">
<tbody>
<tr v-for="row in stationStatusRows" :key="row.label">
<th scope="row">{{ row.label }}</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</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-if="stationModuleRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Module</th>
<th scope="col">Id</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationModuleRows" :key="row.key">
<td>{{ row.module }}</td>
<td>{{ row.moduleId }}</td>
</tr>
</tbody>
</table>
</div>
<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 v-if="stationStorageRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Class</th>
<th scope="col" class="entity-inspector-table__numeric">Used</th>
<th scope="col" class="entity-inspector-table__numeric">Capacity</th>
<th scope="col" class="entity-inspector-table__numeric">Fill</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationStorageRows" :key="row.key">
<td>{{ row.storageClass }}</td>
<td class="entity-inspector-table__numeric">{{ row.used }}</td>
<td class="entity-inspector-table__numeric">{{ row.capacity }}</td>
<td class="entity-inspector-table__numeric">{{ row.fill }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="stationInventoryRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Ware</th>
<th scope="col" class="entity-inspector-table__numeric">Amount</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationInventoryRows" :key="row.key">
<td>{{ row.ware }}</td>
<td class="entity-inspector-table__numeric">{{ row.amount }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="stationStorageRows.length === 0" 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-if="stationProcessRows.length > 0" class="entity-inspector-table-wrap">
<table class="entity-inspector-table">
<thead>
<tr>
<th scope="col">Lane</th>
<th scope="col">Process</th>
<th scope="col" class="entity-inspector-table__numeric">Progress</th>
<th scope="col" class="entity-inspector-table__numeric">Timing</th>
</tr>
</thead>
<tbody>
<tr v-for="row in stationProcessRows" :key="row.key">
<td>{{ row.lane }}</td>
<td>{{ row.label }}</td>
<td class="entity-inspector-table__numeric">{{ row.progress }}</td>
<td class="entity-inspector-table__numeric">{{ row.timing }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="entity-inspector-empty">No active processes.</div>
</div>
</template>