Move command metadata authority to server

This commit is contained in:
Jage9
2026-03-08 19:35:04 -04:00
parent 1741bcc2bc
commit f5cb5ebb78
8 changed files with 174 additions and 20 deletions

View File

@@ -17,6 +17,22 @@ export type ItemPropertyMetadata = {
};
type UiDefinitionsPayload = {
commandMetadata?: {
mainModeActions?: Array<{
id: string;
label?: string;
tooltip?: string;
}>;
};
itemManagement?: {
actions?: Array<{
id: string;
label?: string;
tooltip?: string;
anyPermission?: string;
ownPermission?: string;
}>;
};
itemTypeOrder?: ItemType[];
itemTypes?: Array<{
type: ItemType;
@@ -28,6 +44,17 @@ type UiDefinitionsPayload = {
globalProperties?: Record<string, unknown>;
}>;
};
type ServerCommandMetadata = {
label?: string;
tooltip?: string;
};
type ItemManagementActionMetadata = ServerCommandMetadata & {
anyPermission?: string;
ownPermission?: string;
};
let itemTypeSequence: ItemType[] = [];
let itemTypeLabels: Partial<Record<ItemType, string>> = {};
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
@@ -36,6 +63,8 @@ let itemTypeCapabilities: Partial<Record<ItemType, string[]>> = {};
let itemTypeGlobalProperties: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
let propertyLabelByKey: Record<string, string> = {};
let mainModeCommandMetadataById: Record<string, ServerCommandMetadata> = {};
let itemManagementActionMetadataById: Record<string, ItemManagementActionMetadata> = {};
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys ?? []),
@@ -150,6 +179,16 @@ export function getItemTypeCapabilities(itemType: ItemType): string[] {
return [...(itemTypeCapabilities[itemType] ?? [])];
}
/** Returns server-authored metadata for one server-backed main-mode command id. */
export function getServerMainModeCommandMetadata(commandId: string): ServerCommandMetadata | undefined {
return mainModeCommandMetadataById[commandId];
}
/** Returns server-authored metadata for one item-management action id. */
export function getItemManagementActionMetadata(actionId: string): ItemManagementActionMetadata | undefined {
return itemManagementActionMetadataById[actionId];
}
/** Returns human-facing label for a property key. */
export function itemPropertyLabel(key: string): string {
const metadataLabel = propertyLabelByKey[key];
@@ -237,6 +276,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
itemTypeGlobalProperties = {};
itemTypePropertyMetadata = {};
propertyLabelByKey = {};
mainModeCommandMetadataById = {};
itemManagementActionMetadataById = {};
rebuildEditablePropertyKeySet();
return false;
}
@@ -253,6 +294,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
const nextGlobals: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
const nextPropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
const nextPropertyLabels: Record<string, string> = {};
const nextMainModeCommandMetadata: Record<string, ServerCommandMetadata> = {};
const nextItemManagementActionMetadata: Record<string, ItemManagementActionMetadata> = {};
for (const definition of uiDefinitions.itemTypes) {
if (!definition || typeof definition.type !== 'string') continue;
@@ -295,6 +338,28 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
discoveredOrder.push(definition.type as ItemType);
}
for (const action of uiDefinitions.commandMetadata?.mainModeActions ?? []) {
const id = String(action?.id ?? '').trim();
if (!id) continue;
nextMainModeCommandMetadata[id] = {
label: typeof action?.label === 'string' && action.label.trim().length > 0 ? action.label.trim() : undefined,
tooltip: typeof action?.tooltip === 'string' && action.tooltip.trim().length > 0 ? action.tooltip.trim() : undefined,
};
}
for (const action of uiDefinitions.itemManagement?.actions ?? []) {
const id = String(action?.id ?? '').trim();
if (!id) continue;
nextItemManagementActionMetadata[id] = {
label: typeof action?.label === 'string' && action.label.trim().length > 0 ? action.label.trim() : undefined,
tooltip: typeof action?.tooltip === 'string' && action.tooltip.trim().length > 0 ? action.tooltip.trim() : undefined,
anyPermission:
typeof action?.anyPermission === 'string' && action.anyPermission.trim().length > 0 ? action.anyPermission.trim() : undefined,
ownPermission:
typeof action?.ownPermission === 'string' && action.ownPermission.trim().length > 0 ? action.ownPermission.trim() : undefined,
};
}
itemTypeLabels = nextLabels;
itemTypeTooltips = nextTooltips;
itemTypeEditableProperties = nextEditable;
@@ -302,6 +367,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
itemTypeGlobalProperties = nextGlobals;
itemTypePropertyMetadata = nextPropertyMetadata;
propertyLabelByKey = nextPropertyLabels;
mainModeCommandMetadataById = nextMainModeCommandMetadata;
itemManagementActionMetadataById = nextItemManagementActionMetadata;
itemTypeSequence = explicitOrder ?? discoveredOrder;
rebuildEditablePropertyKeySet();
return itemTypeSequence.length > 0;

View File

@@ -47,6 +47,8 @@ import {
} from './state/gameState';
import {
applyServerItemUiDefinitions,
getItemManagementActionMetadata,
getServerMainModeCommandMetadata,
getItemTypeGlobalProperties,
getItemTypeSequence,
getEditableItemPropertyKeys,
@@ -192,6 +194,7 @@ type AuthPolicy = {
type AdminMenuAction = {
id: string;
label: string;
tooltip?: string;
};
type AdminRoleSummary = {
@@ -220,6 +223,7 @@ type ItemManagementAction = 'delete' | 'transfer';
type ItemManagementOption = {
action: ItemManagementAction;
label: string;
tooltip?: string;
};
type ItemManagementConfirmContext = {
@@ -650,11 +654,12 @@ function applyAuthPermissions(role: string | null | undefined, permissions: stri
}
/** Applies server-authored admin menu actions for current session. */
function applyServerAdminMenuActions(actions: Array<{ id: string; label: string }> | null | undefined): void {
function applyServerAdminMenuActions(actions: Array<{ id: string; label: string; tooltip?: string }> | null | undefined): void {
serverAdminMenuActions = (actions || [])
.map((entry) => ({
id: String(entry.id || '').trim(),
label: String(entry.label || '').trim(),
tooltip: typeof entry.tooltip === 'string' && entry.tooltip.trim().length > 0 ? entry.tooltip.trim() : undefined,
}))
.filter((entry) => entry.id.length > 0 && entry.label.length > 0);
}
@@ -1064,24 +1069,36 @@ function beginItemSelection(
/** Returns whether the local user can delete the provided item. */
function canManageDeleteItem(item: WorldItem): boolean {
if (hasPermission('item.delete.any')) return true;
return hasPermission('item.delete.own') && authUserId.length > 0 && item.createdBy === authUserId;
const metadata = getItemManagementActionMetadata('delete');
if (metadata?.anyPermission && hasPermission(metadata.anyPermission)) return true;
return Boolean(metadata?.ownPermission) && hasPermission(metadata.ownPermission) && authUserId.length > 0 && item.createdBy === authUserId;
}
/** Returns whether the local user can transfer the provided item. */
function canManageTransferItem(item: WorldItem): boolean {
if (hasPermission('item.transfer.any')) return true;
return hasPermission('item.transfer.own') && authUserId.length > 0 && item.createdBy === authUserId;
const metadata = getItemManagementActionMetadata('transfer');
if (metadata?.anyPermission && hasPermission(metadata.anyPermission)) return true;
return Boolean(metadata?.ownPermission) && hasPermission(metadata.ownPermission) && authUserId.length > 0 && item.createdBy === authUserId;
}
/** Builds available item-management actions for one selected item. */
function itemManagementOptionsFor(item: WorldItem): ItemManagementOption[] {
const options: ItemManagementOption[] = [];
const transferMetadata = getItemManagementActionMetadata('transfer');
if (canManageTransferItem(item) && (state.player.id !== null || state.peers.size > 0)) {
options.push({ action: 'transfer', label: 'Transfer item' });
options.push({
action: 'transfer',
label: transferMetadata?.label ?? 'Transfer item',
tooltip: transferMetadata?.tooltip,
});
}
const deleteMetadata = getItemManagementActionMetadata('delete');
if (canManageDeleteItem(item)) {
options.push({ action: 'delete', label: 'Delete item' });
options.push({
action: 'delete',
label: deleteMetadata?.label ?? 'Delete item',
tooltip: deleteMetadata?.tooltip,
});
}
return options;
}
@@ -2570,6 +2587,8 @@ function getAvailableCommandPaletteEntriesForMode(mode: GameMode): Array<Command
});
return descriptors.map((descriptor) => ({
...descriptor,
label: getServerMainModeCommandMetadata(descriptor.id)?.label ?? descriptor.label,
tooltip: getServerMainModeCommandMetadata(descriptor.id)?.tooltip ?? descriptor.tooltip,
run: mainModeCommandHandlers[descriptor.id],
}));
}
@@ -3054,6 +3073,11 @@ function handleItemManageOptionsModeInput(code: string, key: string): void {
audio.sfxUiBlip();
return;
}
if (code === 'Space') {
updateStatus(itemManagementOptions[itemManagementOptionIndex]?.tooltip ?? 'No tooltip available.');
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const option = itemManagementOptions[itemManagementOptionIndex];
if (option.action === 'delete') {
@@ -3176,6 +3200,11 @@ function handleAdminMenuModeInput(code: string, key: string): void {
audio.sfxUiBlip();
return;
}
if (code === 'Space') {
updateStatus(adminMenuActions[adminMenuIndex]?.tooltip ?? 'No tooltip available.');
audio.sfxUiBlip();
return;
}
if (control.type === 'select') {
const selected = adminMenuActions[adminMenuIndex];
if (!selected) return;

View File

@@ -1,5 +1,11 @@
import { z } from 'zod';
const actionMetadataSchema = z.object({
id: z.string(),
label: z.string(),
tooltip: z.string().optional(),
});
export const itemSchema = z.object({
id: z.string(),
type: z.string().min(1),
@@ -59,7 +65,7 @@ export const welcomeMessageSchema = z.object({
username: z.string().nullable().optional(),
role: z.string().nullable().optional(),
permissions: z.array(z.string()).optional(),
adminMenuActions: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
adminMenuActions: z.array(actionMetadataSchema).optional(),
policy: z
.object({
usernameMinLength: z.number().int().positive(),
@@ -105,7 +111,22 @@ export const welcomeMessageSchema = z.object({
),
adminMenu: z
.object({
actions: z.array(z.object({ id: z.string(), label: z.string() })),
actions: z.array(actionMetadataSchema),
})
.optional(),
commandMetadata: z
.object({
mainModeActions: z.array(actionMetadataSchema).optional(),
})
.optional(),
itemManagement: z
.object({
actions: z.array(
actionMetadataSchema.extend({
anyPermission: z.string().optional(),
ownPermission: z.string().optional(),
}),
),
})
.optional(),
})
@@ -133,7 +154,7 @@ export const authResultSchema = z.object({
username: z.string().optional(),
role: z.string().optional(),
permissions: z.array(z.string()).optional(),
adminMenuActions: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
adminMenuActions: z.array(actionMetadataSchema).optional(),
nickname: z.string().optional(),
authPolicy: z
.object({
@@ -289,7 +310,7 @@ export const authPermissionsSchema = z.object({
type: z.literal('auth_permissions'),
role: z.string(),
permissions: z.array(z.string()),
adminMenuActions: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
adminMenuActions: z.array(actionMetadataSchema).optional(),
});
const adminRoleSummarySchema = z.object({