From f7e29ec968a8c162cb7fe6581d610080cf4ab11c Mon Sep 17 00:00:00 2001 From: Jage9 Date: Tue, 24 Feb 2026 02:49:17 -0500 Subject: [PATCH] client: require server item schema and drive property UI from metadata --- client/public/version.js | 2 +- client/src/items/itemPropertyEditor.ts | 133 +++++++++---------------- client/src/items/itemRegistry.ts | 120 +++++++++++++--------- client/src/main.ts | 38 ++++++- client/src/network/messageHandlers.ts | 10 +- docs/item-schema.md | 10 +- docs/item-types.md | 18 ++-- docs/protocol-notes.md | 5 +- 8 files changed, 183 insertions(+), 153 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index dfe099a..6164bd4 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.02.24 R226"; +window.CHGRID_WEB_VERSION = "2026.02.24 R227"; // 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/itemPropertyEditor.ts b/client/src/items/itemPropertyEditor.ts index cf060aa..6037e7e 100644 --- a/client/src/items/itemPropertyEditor.ts +++ b/client/src/items/itemPropertyEditor.ts @@ -26,16 +26,20 @@ type EditorDeps = { getItemPropertyOptionValues: (key: string) => string[] | undefined; openItemPropertyOptionSelect: (item: WorldItem, key: string) => void; describeItemPropertyHelp: (item: WorldItem, key: string) => string; - getItemPropertyMetadata: (itemType: WorldItem['type'], key: string) => { valueType?: string; range?: { min: number; max: number; step?: number } } | undefined; + getItemPropertyMetadata: ( + itemType: WorldItem['type'], + key: string, + ) => { + valueType?: string; + maxLength?: number; + range?: { min: number; max: number; step?: number }; + } | undefined; validateNumericItemPropertyInput: ( item: WorldItem, key: string, rawValue: string, requireInteger: boolean, ) => { ok: true; value: number } | { ok: false; message: string }; - clampEffectLevel: (value: number) => number; - effectIds: Set; - effectSequenceIdsCsv: string; applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean, allowReplaceOnNextType?: boolean) => void; setReplaceTextOnNextType: (value: boolean) => void; suppressItemPropertyEchoMs: (ms: number) => void; @@ -115,7 +119,7 @@ export function createItemPropertyEditor(deps: EditorDeps): { if (metadata?.valueType === 'boolean') { let current = item.params[selectedKey]; if (typeof current !== 'boolean') { - current = selectedKey === 'enabled' ? item.params.enabled !== false : item.params[selectedKey] === true; + current = item.params[selectedKey] === true; } const nextValue = !current; deps.suppressItemPropertyEchoMs(600); @@ -163,24 +167,13 @@ export function createItemPropertyEditor(deps: EditorDeps): { deps.sfxUiCancel(); return; } - if (selectedKey === 'enabled') { - const nextEnabled = item.params.enabled === false; - deps.signalingSend({ type: 'item_update', itemId, params: { enabled: nextEnabled } }); - deps.updateStatus(`enabled: ${nextEnabled ? 'on' : 'off'}`); - deps.sfxUiBlip(); - return; - } - if (selectedKey === 'directional') { - const nextDirectional = item.params.directional !== true; - deps.signalingSend({ type: 'item_update', itemId, params: { directional: nextDirectional } }); - deps.updateStatus(`directional: ${nextDirectional ? 'on' : 'off'}`); - deps.sfxUiBlip(); - return; - } - if (selectedKey === 'use24Hour') { - const nextUse24Hour = item.params.use24Hour !== true; - deps.signalingSend({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } }); - deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${nextUse24Hour ? 'on' : 'off'}`); + const metadata = deps.getItemPropertyMetadata(item.type, selectedKey); + if (metadata?.valueType === 'boolean') { + const current = item.params[selectedKey]; + const nextValue = typeof current === 'boolean' ? !current : deps.getItemPropertyValue(item, selectedKey).toLowerCase() !== 'on'; + deps.signalingSend({ type: 'item_update', itemId, params: { [selectedKey]: nextValue } }); + deps.onPreviewPropertyChange?.(item, selectedKey, nextValue); + deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${nextValue ? 'on' : 'off'}`); deps.sfxUiBlip(); return; } @@ -190,13 +183,14 @@ export function createItemPropertyEditor(deps: EditorDeps): { } deps.state.mode = 'itemPropertyEdit'; deps.state.editingPropertyKey = selectedKey; + const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey); deps.state.nicknameInput = selectedKey === 'title' ? item.title - : selectedKey === 'enabled' - ? item.params.enabled === false - ? 'off' - : 'on' + : selectedMetadata?.valueType === 'boolean' + ? item.params[selectedKey] === true + ? 'on' + : 'off' : String(item.params[selectedKey] ?? ''); deps.state.cursorPos = deps.state.nicknameInput.length; deps.setReplaceTextOnNextType(true); @@ -271,6 +265,8 @@ export function createItemPropertyEditor(deps: EditorDeps): { const editAction = getEditSessionAction(code); if (editAction === 'submit') { const value = deps.state.nicknameInput.trim(); + const metadata = deps.getItemPropertyMetadata(item.type, propertyKey); + const valueType = metadata?.valueType; const sendItemParams = (params: Record): void => { deps.signalingSend({ type: 'item_update', itemId, params }); for (const [key, nextValue] of Object.entries(params)) { @@ -286,18 +282,14 @@ export function createItemPropertyEditor(deps: EditorDeps): { } return { ok: true, value: ['on', 'true', '1', 'yes'].includes(normalized) }; }; - const submitNumericParam = ( - targetKey: string, - requireInteger: boolean, - transform?: (num: number) => number, - ): boolean => { - const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, requireInteger); + const submitNumericParam = (targetKey: string): boolean => { + const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, false); if (!parsed.ok) { deps.updateStatus(parsed.message); deps.sfxUiCancel(); return false; } - sendItemParams({ [targetKey]: transform ? transform(parsed.value) : parsed.value }); + sendItemParams({ [targetKey]: parsed.value }); return true; }; if (propertyKey === 'title') { @@ -307,62 +299,34 @@ export function createItemPropertyEditor(deps: EditorDeps): { return; } deps.signalingSend({ type: 'item_update', itemId, title: value }); - } else if (propertyKey === 'streamUrl') { - sendItemParams({ streamUrl: value }); - } else if (propertyKey === 'enabled' || propertyKey === 'directional') { + } else if (valueType === 'boolean') { const toggle = parseToggleValue(value, propertyKey); if (!toggle.ok) return; sendItemParams({ [propertyKey]: toggle.value }); - } else if ( - propertyKey === 'mediaVolume' || - propertyKey === 'emitVolume' || - propertyKey === 'emitRange' || - propertyKey === 'octave' || - propertyKey === 'attack' || - propertyKey === 'decay' || - propertyKey === 'release' || - propertyKey === 'brightness' || - propertyKey === 'sides' || - propertyKey === 'number' - ) { - if (!submitNumericParam(propertyKey, true)) return; - } else if (propertyKey === 'emitSoundSpeed' || propertyKey === 'emitSoundTempo') { - if (!submitNumericParam(propertyKey, false)) return; - } else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') { - const normalized = value.trim().toLowerCase(); - if (!deps.effectIds.has(normalized)) { - deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${deps.effectSequenceIdsCsv}.`); + } else if (valueType === 'number') { + if (!submitNumericParam(propertyKey)) return; + } else if (valueType === 'list') { + const options = deps.getItemPropertyOptionValues(propertyKey) ?? []; + if (options.length === 0) { + deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`); + deps.sfxUiCancel(); + return; + } + const normalized = value.toLowerCase(); + const matched = options.find((option) => option.toLowerCase() === normalized); + if (!matched) { + deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${options.join(', ')}.`); + deps.sfxUiCancel(); + return; + } + sendItemParams({ [propertyKey]: matched }); + } else { + if (metadata?.maxLength !== undefined && value.length > metadata.maxLength) { + deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be ${metadata.maxLength} characters or less.`); deps.sfxUiCancel(); return; } - sendItemParams({ [propertyKey]: normalized }); - } else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') { - if (!submitNumericParam(propertyKey, false, (num) => deps.clampEffectLevel(num))) return; - } else if (propertyKey === 'facing') { - if (!submitNumericParam(propertyKey, false)) return; - } else if (propertyKey === 'useSound' || propertyKey === 'emitSound') { sendItemParams({ [propertyKey]: value }); - } else if (propertyKey === 'spaces') { - const spaces = value - .split(',') - .map((token) => token.trim()) - .filter((token) => token.length > 0); - if (spaces.length === 0) { - deps.updateStatus('spaces must include at least one comma-delimited value.'); - deps.sfxUiCancel(); - return; - } - if (spaces.length > 100) { - deps.updateStatus('spaces supports up to 100 values.'); - deps.sfxUiCancel(); - return; - } - if (spaces.some((token) => token.length > 80)) { - deps.updateStatus('each space must be 80 chars or less.'); - deps.sfxUiCancel(); - return; - } - sendItemParams({ spaces: spaces.join(', ') }); } deps.state.mode = 'itemProperties'; deps.state.editingPropertyKey = null; @@ -377,7 +341,8 @@ export function createItemPropertyEditor(deps: EditorDeps): { deps.sfxUiCancel(); return; } - deps.applyTextInputEdit(code, key, 500, ctrlKey, true); + const maxLength = deps.getItemPropertyMetadata(item.type, propertyKey)?.maxLength ?? 500; + deps.applyTextInputEdit(code, key, maxLength, ctrlKey, true); } function handleItemPropertyOptionSelectModeInput(code: string, key: string): void { diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 54b321b..fefc5b4 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -1,6 +1,4 @@ import { type ItemType, type WorldItem } from '../state/gameState'; -import { CLOCK_TIME_ZONE_OPTIONS } from './types/clock'; -import { DEFAULT_ITEM_TYPE_DEFINITIONS, DEFAULT_ITEM_TYPE_SEQUENCE } from './types'; export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound'; @@ -8,6 +6,7 @@ export type ItemPropertyMetadata = { valueType?: ItemPropertyValueType; tooltip?: string; maxLength?: number; + visibleWhen?: Record; range?: { min: number; max: number; @@ -27,42 +26,21 @@ type UiDefinitionsPayload = { globalProperties?: Record; }>; }; - -let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE]; -let itemTypeLabels: Record = {} as Record; +let itemTypeSequence: ItemType[] = []; +let itemTypeLabels: Partial> = {}; let itemTypeTooltips: Partial> = {}; -let itemTypeEditableProperties: Record = {} as Record; -let itemTypeGlobalProperties: Record> = {} as Record< - ItemType, - Record ->; +let itemTypeEditableProperties: Partial> = {}; +let itemTypeGlobalProperties: Partial>> = {}; let optionItemPropertyValues: Partial> = {}; let itemTypePropertyMetadata: Partial>> = {}; -for (const definition of DEFAULT_ITEM_TYPE_DEFINITIONS) { - itemTypeLabels[definition.type] = definition.label; - if (definition.tooltip) { - itemTypeTooltips[definition.type] = definition.tooltip; - } - itemTypeEditableProperties[definition.type] = [...definition.editableProperties]; - itemTypeGlobalProperties[definition.type] = { ...definition.globalProperties }; - if (definition.propertyMetadata) { - itemTypePropertyMetadata[definition.type] = { ...definition.propertyMetadata }; - } - if (definition.propertyOptions) { - for (const [key, values] of Object.entries(definition.propertyOptions)) { - optionItemPropertyValues[key] = [...values]; - } - } -} - export let EDITABLE_ITEM_PROPERTY_KEYS = new Set( - Object.values(itemTypeEditableProperties).flatMap((keys) => keys), + Object.values(itemTypeEditableProperties).flatMap((keys) => keys ?? []), ); /** Rebuilds the flattened editable-key lookup after item-type definitions are replaced. */ function rebuildEditablePropertyKeySet(): void { - EDITABLE_ITEM_PROPERTY_KEYS = new Set(Object.values(itemTypeEditableProperties).flatMap((keys) => keys)); + EDITABLE_ITEM_PROPERTY_KEYS = new Set(Object.values(itemTypeEditableProperties).flatMap((keys) => keys ?? [])); } /** Normalizes server-provided property metadata into strict client metadata shape. */ @@ -85,6 +63,17 @@ function normalizePropertyMetadataRecord(raw: Record | undefine metadata.maxLength = Math.floor(maxLength); } } + if (valueObj.visibleWhen && typeof valueObj.visibleWhen === 'object') { + const visibleWhen: Record = {}; + for (const [conditionKey, conditionValue] of Object.entries(valueObj.visibleWhen as Record)) { + if (typeof conditionValue === 'string' || typeof conditionValue === 'number' || typeof conditionValue === 'boolean') { + visibleWhen[conditionKey] = conditionValue; + } + } + if (Object.keys(visibleWhen).length > 0) { + metadata.visibleWhen = visibleWhen; + } + } const range = valueObj.range; if (range && typeof range === 'object') { const rangeObj = range as Record; @@ -106,7 +95,7 @@ function normalizePropertyMetadataRecord(raw: Record | undefine /** Returns current timezone option list used by clock item properties. */ export function getClockTimeZoneOptions(): string[] { - return [...(optionItemPropertyValues.timeZone ?? CLOCK_TIME_ZONE_OPTIONS)]; + return [...(optionItemPropertyValues.timeZone ?? [])]; } /** Returns default timezone used by clock items when no override is set. */ @@ -171,7 +160,11 @@ export function itemPropertyLabel(key: string): string { /** Returns editable properties for one item instance/type. */ export function getEditableItemPropertyKeys(item: WorldItem): string[] { - return [...(itemTypeEditableProperties[item.type] ?? ['title'])]; + const rawKeys = itemTypeEditableProperties[item.type]; + if (!rawKeys || rawKeys.length === 0) { + return []; + } + return rawKeys.filter((key) => isItemPropertyVisible(item, key)); } /** Returns inspect-mode property key list (editable first, then system/global extras). */ @@ -201,6 +194,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] { const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b)); for (const key of paramKeys) { + if (!isItemPropertyVisible(item, key)) continue; if (seen.has(key)) continue; seen.add(key); allKeys.push(key); @@ -208,6 +202,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] { const globalKeys = Object.keys(itemTypeGlobalProperties[item.type] ?? {}).sort((a, b) => a.localeCompare(b)); for (const key of globalKeys) { + if (!isItemPropertyVisible(item, key)) continue; if (seen.has(key)) continue; seen.add(key); allKeys.push(key); @@ -217,24 +212,30 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] { } /** Applies server-supplied UI/catalog definitions for item types, properties, and options. */ -export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void { - if (!uiDefinitions) return; - - if (Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0) { - itemTypeSequence = uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[]; - } - - if (!Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) { +export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): boolean { + if (!uiDefinitions || !Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) { + itemTypeSequence = []; + itemTypeLabels = {}; + itemTypeTooltips = {}; + itemTypeEditableProperties = {}; + itemTypeGlobalProperties = {}; + optionItemPropertyValues = {}; + itemTypePropertyMetadata = {}; rebuildEditablePropertyKeySet(); - return; + return false; } - const nextLabels = { ...itemTypeLabels }; - const nextTooltips = { ...itemTypeTooltips }; - const nextEditable = { ...itemTypeEditableProperties }; - const nextGlobals = { ...itemTypeGlobalProperties }; - const nextOptions: Partial> = { ...optionItemPropertyValues }; - const nextPropertyMetadata = { ...itemTypePropertyMetadata }; + const explicitOrder = + Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0 + ? (uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[]) + : null; + + const nextLabels: Partial> = {}; + const nextTooltips: Partial> = {}; + const nextEditable: Partial> = {}; + const nextGlobals: Partial>> = {}; + const nextOptions: Partial> = {}; + const nextPropertyMetadata: Partial>> = {}; for (const definition of uiDefinitions.itemTypes) { if (!definition || typeof definition.type !== 'string') continue; @@ -271,11 +272,38 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload } } + const discoveredOrder: ItemType[] = []; + for (const definition of uiDefinitions.itemTypes) { + if (!definition || typeof definition.type !== 'string') continue; + discoveredOrder.push(definition.type as ItemType); + } + itemTypeLabels = nextLabels; itemTypeTooltips = nextTooltips; itemTypeEditableProperties = nextEditable; itemTypeGlobalProperties = nextGlobals; optionItemPropertyValues = nextOptions; itemTypePropertyMetadata = nextPropertyMetadata; + itemTypeSequence = explicitOrder ?? discoveredOrder; rebuildEditablePropertyKeySet(); + return itemTypeSequence.length > 0; +} + +/** Returns whether a property is currently visible for an item based on metadata visibility rules. */ +export function isItemPropertyVisible(item: WorldItem, key: string): boolean { + const metadata = getItemPropertyMetadata(item.type, key); + const visibilityRule = (metadata as Record | undefined)?.visibleWhen; + if (!visibilityRule || typeof visibilityRule !== 'object') { + return true; + } + const conditions = visibilityRule as Record; + for (const [conditionKey, expected] of Object.entries(conditions)) { + const actual = + item.params[conditionKey] ?? + getItemTypeGlobalProperties(item.type)[conditionKey]; + if (actual !== expected) { + return false; + } + } + return true; } diff --git a/client/src/main.ts b/client/src/main.ts index 314c6fd..59d94fb 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,9 +1,7 @@ import './styles.css'; import { AudioEngine } from './audio/audioEngine'; import { - EFFECT_IDS, EFFECT_SEQUENCE, - clampEffectLevel, } from './audio/effects'; import { RadioStationRuntime, @@ -235,6 +233,7 @@ let lastSubscriptionRefreshTileY = Math.round(state.player.y); let subscriptionRefreshInFlight = false; let subscriptionRefreshPending = false; let suppressItemPropertyEchoUntilMs = 0; +let itemPropertiesShowAll = false; let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopToken = 0; let activeTeleport: @@ -832,6 +831,7 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins /** Opens item property browsing/editing mode for one item. */ function beginItemProperties(item: WorldItem, showAll = false): void { + itemPropertiesShowAll = showAll; state.selectedItemId = item.id; state.mode = 'itemProperties'; state.editingPropertyKey = null; @@ -843,12 +843,42 @@ function beginItemProperties(item: WorldItem, showAll = false): void { state.itemPropertyKeys = getEditableItemPropertyKeys(item); } state.itemPropertyIndex = 0; + if (state.itemPropertyKeys.length === 0) { + updateStatus('No properties available.'); + audio.sfxUiCancel(); + state.mode = 'normal'; + state.selectedItemId = null; + return; + } const key = state.itemPropertyKeys[0]; const value = getItemPropertyValue(item, key); updateStatus(`${itemPropertyLabel(key)}: ${value}`); audio.sfxUiBlip(); } +/** Recomputes visible property rows for the active item-property view after item updates. */ +function recomputeActiveItemPropertyKeys(itemId: string): void { + if (state.mode !== 'itemProperties' || state.selectedItemId !== itemId) { + return; + } + const item = state.items.get(itemId); + if (!item) { + return; + } + const previousKey = state.itemPropertyKeys[state.itemPropertyIndex] ?? null; + const nextKeys = itemPropertiesShowAll ? getInspectItemPropertyKeys(item) : getEditableItemPropertyKeys(item); + state.itemPropertyKeys = nextKeys; + if (nextKeys.length === 0) { + state.itemPropertyIndex = 0; + return; + } + if (previousKey && nextKeys.includes(previousKey)) { + state.itemPropertyIndex = nextKeys.indexOf(previousKey); + return; + } + state.itemPropertyIndex = Math.max(0, Math.min(state.itemPropertyIndex, nextKeys.length - 1)); +} + /** Sends an item-use request for the selected item. */ function useItem(item: WorldItem): void { signaling.send({ type: 'item_use', itemId: item.id }); @@ -1362,6 +1392,7 @@ const onAppMessage = createOnMessageHandler({ audioUiCancel: () => audio.sfxUiCancel(), NICKNAME_STORAGE_KEY, getCarriedItemId: () => getCarriedItem()?.id ?? null, + recomputeActiveItemPropertyKeys, itemPropertyLabel, getItemPropertyValue, getItemById: (itemId) => state.items.get(itemId), @@ -2121,9 +2152,6 @@ const itemPropertyEditor = createItemPropertyEditor({ describeItemPropertyHelp, getItemPropertyMetadata, validateNumericItemPropertyInput, - clampEffectLevel, - effectIds: EFFECT_IDS as Set, - effectSequenceIdsCsv: EFFECT_SEQUENCE.map((effect) => effect.id).join(', '), applyTextInputEdit, setReplaceTextOnNextType: (value) => { replaceTextOnNextType = value; diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 5c6ac47..240f14c 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -9,7 +9,7 @@ type MessageHandlerDeps = { setWorldGridSize: (size: number) => void; setConnecting: (value: boolean) => void; rendererSetGridSize: (size: number) => void; - applyServerItemUiDefinitions: (defs: unknown) => void; + applyServerItemUiDefinitions: (defs: unknown) => boolean; state: { addItemTypeIndex: number; player: { id: string | null; nickname: string; x: number; y: number }; @@ -62,6 +62,7 @@ type MessageHandlerDeps = { audioUiCancel: () => void; NICKNAME_STORAGE_KEY: string; getCarriedItemId: () => string | null; + recomputeActiveItemPropertyKeys: (itemId: string) => void; itemPropertyLabel: (key: string) => string; getItemPropertyValue: (item: WorldItem, key: string) => string; getItemById: (itemId: string) => WorldItem | undefined; @@ -82,7 +83,11 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco deps.setWorldGridSize(message.worldConfig.gridSize); } deps.rendererSetGridSize(deps.getWorldGridSize()); - deps.applyServerItemUiDefinitions(message.uiDefinitions); + const schemaReady = deps.applyServerItemUiDefinitions(message.uiDefinitions); + if (!schemaReady) { + deps.updateStatus('Item schema missing from server. Item menus unavailable.'); + deps.audioUiCancel(); + } deps.state.addItemTypeIndex = 0; deps.state.player.id = message.id; deps.state.running = true; @@ -207,6 +212,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco carrierId: message.item.carrierId ?? null, }); deps.state.carriedItemId = deps.getCarriedItemId(); + deps.recomputeActiveItemPropertyKeys(message.item.id); if (deps.state.mode === 'itemProperties' && deps.state.selectedItemId === message.item.id) { const key = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex]; if (key && deps.shouldAnnounceItemPropertyEcho()) { diff --git a/docs/item-schema.md b/docs/item-schema.md index 2186257..1e7b2d5 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -49,8 +49,8 @@ - Persisted state stores only instance data. - Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`. -- Per-type use/update validation and message behavior are implemented in per-item modules under `server/app/items/` and wired in `server/app/items/registry.py`. -- Client-side add/edit metadata is handled in `client/src/items/itemRegistry.ts`. +- Per-type use/update validation and message behavior are implemented in per-item modules under `server/app/items/`, discovered via plugins in `server/app/items/types/*/plugin.py`. +- Client-side add/edit metadata is consumed from `welcome.uiDefinitions` via `client/src/items/itemRegistry.ts` (no local fallback definitions). - End-to-end add-item template: `docs/item-type-template.md`. ## Type Params @@ -77,7 +77,8 @@ - `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`. - `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `mediaEffectValue`: number, range `0-100`, precision `0.1`. -- `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`). +- `facing`: number, range `0-360`, step `1` (used when `directional=true`). +- UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata). - `emitRange`: integer, range `5-20`, default `20`. ### `dice` @@ -148,7 +149,8 @@ - `enabled`: boolean (or `on/off` in updates), default `true`. - `directional`: boolean (or `on/off` in updates), default `false`. -- `facing`: number, range `0-360`, precision `0.1`. +- `facing`: number, range `0-360`, step `1`. +- UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata). - `emitRange`: integer, range `1-20`, default `15`. - `emitVolume`: integer, range `0-100`, default `100`. - `emitSoundSpeed`: integer, range `0-100`, default `50`; controls emitted sound speed/pitch (`0=0.5x`, `50=1.0x`, `100=2.0x`). diff --git a/docs/item-types.md b/docs/item-types.md index d4edef0..9f9184c 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -187,9 +187,9 @@ This is behavior-focused documentation for item types and their defaults. - `emitRange`: integer `5..20` - Instrument changes reset `voiceMode`/`octave`/`attack`/`decay`/`release`/`brightness` to instrument defaults. -## Adding A New Item Type (Registry V1) +## Adding A New Item Type (Plugin Discovery) -Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry. +Server is the source of truth for item type definitions and metadata. The client consumes server `welcome.uiDefinitions` and only provides UX/runtime behavior. For a full copy/paste example with plain-English explanation, see `docs/item-type-template.md`. @@ -197,13 +197,15 @@ For a full copy/paste example with plain-English explanation, see `docs/item-typ - defaults/capabilities - property metadata/options - `validate_update` and `use_item` -2. Server registry: add one entry in `server/app/items/registry.py`: - - `ITEM_MODULES` - - `ITEM_TYPE_ORDER` (if ordering changes) +2. Server plugin: add `server/app/items/types//plugin.py` exporting `ITEM_TYPE_PLUGIN` with: + - `type` + - `order` + - `module` + The server auto-discovers plugins at boot, so no central registry edit is needed. 3. Server models: extend `ItemType` literals in `server/app/models.py` and any packet enums that list item types. -4. Client fallback registry: add type defaults in `client/src/items/itemRegistry.ts` (`DEFAULT_ITEM_TYPE_SEQUENCE`, editable/global fallback metadata). -5. Client protocol/state types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`. -6. Tests: add or update server tests under `server/tests/` for use/update validation and cooldown behavior. +4. Client protocol/state types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`. +5. Client runtime behavior: add `client/src/items/types//behavior.ts` only if custom client runtime is needed. +6. Tests: add or update server tests under `server/tests/` for use/update validation, unknown-key stripping, and `uiDefinitions` completeness. ### Example Shape diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 53435f1..db71ce4 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -53,10 +53,9 @@ This is a behavior guide for packet semantics beyond raw schemas. - `itemTypes[].tooltip`: item-level tooltip/help text - `itemTypes[].editableProperties`: editable property keys by item type - `itemTypes[].propertyOptions`: menu options for property keys (for example clock `timeZone`) - - `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `range`, optional `tooltip`) + - `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `range`, optional `tooltip`, optional `maxLength`, optional `visibleWhen`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`) - -- Clients keep local fallback defaults but should prefer server-provided metadata when present. +- Client item UI requires this metadata from the server; there is no fallback item definition map. ## Validation Boundaries