From f5cb5ebb785b292ba21753768fba8ac582c3da64 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sun, 8 Mar 2026 19:35:04 -0400 Subject: [PATCH] Move command metadata authority to server --- client/public/version.js | 2 +- client/src/items/itemRegistry.ts | 67 ++++++++++++++++++++++++++++++++ client/src/main.ts | 43 ++++++++++++++++---- client/src/network/protocol.ts | 29 ++++++++++++-- docs/controls.md | 2 + docs/protocol-notes.md | 4 +- docs/runtime-flow.md | 2 +- server/app/server.py | 45 ++++++++++++++++++--- 8 files changed, 174 insertions(+), 20 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 88425ee..254afb9 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.03.08 R335"; +window.CHGRID_WEB_VERSION = "2026.03.08 R336"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index aaf7d1d..8372ce6 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -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; }>; }; + +type ServerCommandMetadata = { + label?: string; + tooltip?: string; +}; + +type ItemManagementActionMetadata = ServerCommandMetadata & { + anyPermission?: string; + ownPermission?: string; +}; + let itemTypeSequence: ItemType[] = []; let itemTypeLabels: Partial> = {}; let itemTypeTooltips: Partial> = {}; @@ -36,6 +63,8 @@ let itemTypeCapabilities: Partial> = {}; let itemTypeGlobalProperties: Partial>> = {}; let itemTypePropertyMetadata: Partial>> = {}; let propertyLabelByKey: Record = {}; +let mainModeCommandMetadataById: Record = {}; +let itemManagementActionMetadataById: Record = {}; export let EDITABLE_ITEM_PROPERTY_KEYS = new Set( 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>> = {}; const nextPropertyMetadata: Partial>> = {}; const nextPropertyLabels: Record = {}; + const nextMainModeCommandMetadata: Record = {}; + const nextItemManagementActionMetadata: Record = {}; 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; diff --git a/client/src/main.ts b/client/src/main.ts index 1d20688..b5aca81 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -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 ({ ...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; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index ddf1f1a..c79b27d 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -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({ diff --git a/docs/controls.md b/docs/controls.md index 5869d7e..5addf65 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -33,6 +33,7 @@ This document is the authoritative keymap for the client. - `Shift+O`: Inspect all item properties - `D`: Pick up/drop item - `Z`: Item management menu (delete/transfer when permitted) +- `Space` in item management menu: Read tooltip/help for the selected action - `Enter`: Use item - `Shift+Enter`: Secondary item action @@ -104,6 +105,7 @@ Applies to effect select, user/item list modes, item selection, item property li ## Admin Modes - `Shift+Z`: Open admin menu +- `Space` on admin root actions: Read tooltip/help for the selected action - Admin menu options are permission-gated and include: - role management - change user role diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 988ee16..bd389c6 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -111,7 +111,9 @@ This is a behavior guide for packet semantics beyond raw schemas. - `itemTypes[].editableProperties`: editable property keys by item type - `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `label`, optional `range`, optional `tooltip`, optional `maxLength`, optional `options`, optional `visibleWhen`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`, `emitInitialDelay`, `emitLoopDelay`) - - `adminMenu.actions`: server-authored admin root menu labels/ordering for the authenticated user. + - `commandMetadata.mainModeActions`: server-authored labels/tooltips for server-backed main-mode commands used by the client command palette + - `itemManagement.actions`: server-authored labels/tooltips and permission-key metadata for item-management actions (`transfer`, `delete`) + - `adminMenu.actions`: server-authored admin root menu labels/tooltips/ordering for the authenticated user - Client item UI requires this metadata from the server; there is no fallback item definition map. - Client property help/type rendering is metadata-driven; it does not infer fallback types/tooltips from hardcoded key heuristics. - `visibleWhen` supports equality checks and string negation via `!` prefix (example: `{"mediaEffect": "!off"}`). diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 3896c24..8c40879 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -20,7 +20,7 @@ - uses `welcome.player` as authoritative starting position (restored from server-side account state when available) - records `welcome.serverInfo` (`instanceId`, `version`) for restart detection - if `welcome.serverInfo.version` differs from running client version, auto-reloads the page - - applies `welcome.uiDefinitions` for item menus/properties/options and admin menu labels/order + - applies `welcome.uiDefinitions` for item menus/properties/options, server-backed command metadata, item-management metadata, and admin menu labels/order - sends initial `update_position` echo from server-assigned starting tile - sends initial `update_nickname` - creates peer runtimes for known users diff --git a/server/app/server.py b/server/app/server.py index 49918fa..46e53af 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -134,11 +134,42 @@ AUTH_SESSION_COOKIE_CLIENT_HEADER = "X-Chgrid-Auth-Client" AUTH_LOGIN_FAILURE_MESSAGE = "We couldn't log you in. Check your details and try again." AUTH_RESUME_FAILURE_MESSAGE = "We couldn't restore your session. Please log in again." ADMIN_MENU_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = ( - {"id": "manage_roles", "label": "Role management", "permission": "role.manage"}, - {"id": "change_user_role", "label": "Change user role", "permission": "user.change_role"}, - {"id": "ban_user", "label": "Ban user", "permission": "user.ban_unban"}, - {"id": "unban_user", "label": "Unban user", "permission": "user.ban_unban"}, - {"id": "delete_account", "label": "Delete account", "permission": "account.delete.any"}, + {"id": "manage_roles", "label": "Role management", "tooltip": "Manage roles and their permission sets.", "permission": "role.manage"}, + {"id": "change_user_role", "label": "Change user role", "tooltip": "Change a user's assigned role.", "permission": "user.change_role"}, + {"id": "ban_user", "label": "Ban user", "tooltip": "Disable a user account.", "permission": "user.ban_unban"}, + {"id": "unban_user", "label": "Unban user", "tooltip": "Re-enable a disabled user account.", "permission": "user.ban_unban"}, + {"id": "delete_account", "label": "Delete account", "tooltip": "Delete a user account permanently.", "permission": "account.delete.any"}, +) + +ITEM_MANAGEMENT_ACTION_DEFINITIONS: tuple[dict[str, str], ...] = ( + { + "id": "transfer", + "label": "Transfer item", + "tooltip": "Transfer this item to another user.", + "anyPermission": "item.transfer.any", + "ownPermission": "item.transfer.own", + }, + { + "id": "delete", + "label": "Delete item", + "tooltip": "Delete this item from the world.", + "anyPermission": "item.delete.any", + "ownPermission": "item.delete.own", + }, +) + +MAIN_MODE_SERVER_COMMAND_DEFINITIONS: tuple[dict[str, str], ...] = ( + {"id": "addItem", "label": "Add item", "tooltip": "Open the add-item menu."}, + {"id": "useItem", "label": "Use item", "tooltip": "Use the carried item or a usable item on your current square."}, + { + "id": "secondaryUseItem", + "label": "Secondary item action", + "tooltip": "Run the secondary action for the carried item or a usable item on your current square.", + }, + {"id": "pickupDropItem", "label": "Pick up or drop item", "tooltip": "Pick up an item or drop your carried item."}, + {"id": "openItemManagement", "label": "Item management", "tooltip": "Open item management actions for items on your square."}, + {"id": "editItem", "label": "Edit item properties", "tooltip": "Edit the carried item or an item on your current square."}, + {"id": "inspectItem", "label": "Inspect item properties", "tooltip": "Inspect all properties for the carried item or an item on your current square."}, ) @@ -347,7 +378,7 @@ class SignalingServer: return [] client_permissions = client.permissions or set() return [ - {"id": action["id"], "label": action["label"]} + {"id": action["id"], "label": action["label"], "tooltip": action["tooltip"]} for action in ADMIN_MENU_ACTION_DEFINITIONS if action["permission"] in client_permissions ] @@ -1695,6 +1726,8 @@ class SignalingServer: return { "itemTypeOrder": list(ITEM_TYPE_SEQUENCE), "itemTypes": item_types, + "commandMetadata": {"mainModeActions": list(MAIN_MODE_SERVER_COMMAND_DEFINITIONS)}, + "itemManagement": {"actions": list(ITEM_MANAGEMENT_ACTION_DEFINITIONS)}, "adminMenu": {"actions": self._build_admin_menu_actions_for_client(client)}, }