From 0656de7485777269e3efc2076c8f43fd68da52ed Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 20:47:02 -0500 Subject: [PATCH] Add item/property tooltip metadata and schema-driven ranges --- client/public/version.js | 2 +- client/src/items/itemRegistry.ts | 111 +++++++++++++++++++++++ client/src/main.ts | 147 ++++++++++++++++++++++++++----- client/src/network/protocol.ts | 17 ++++ docs/controls.md | 1 + docs/protocol-notes.md | 2 + server/app/item_catalog.py | 62 +++++++++++++ server/app/server.py | 4 + 8 files changed, 325 insertions(+), 21 deletions(-) diff --git a/client/public/version.js b/client/public/version.js index 71ed924..c393788 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.21 R120"; +window.CHGRID_WEB_VERSION = "2026.02.21 R121"; // 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 9c7a7c3..4178804 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -61,13 +61,62 @@ const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record = { + radio_station: 'Streams audio from a URL and can emit directional sound on the grid.', + dice: 'Roll one or more dice and report each value plus total.', + wheel: 'Spin a wheel from a comma-delimited list of spaces.', + clock: 'Speaks the current time using its configured timezone and format.', +}; + +const DEFAULT_ITEM_PROPERTY_METADATA: Record> = { + radio_station: { + title: { valueType: 'text', tooltip: 'Display name spoken and shown for this item.' }, + streamUrl: { valueType: 'text', tooltip: 'Audio stream URL used by this radio.' }, + enabled: { valueType: 'boolean', tooltip: 'Turns playback on or off for this radio.' }, + channel: { valueType: 'list', tooltip: 'Select stereo, mono, left-only, or right-only channel mix.' }, + volume: { valueType: 'number', tooltip: 'Playback volume percent for this radio.', range: { min: 0, max: 100, step: 1 } }, + effect: { valueType: 'list', tooltip: 'Select the active radio effect.' }, + effectValue: { valueType: 'number', tooltip: 'Amount for the selected effect.', range: { min: 0, max: 100, step: 0.1 } }, + facing: { valueType: 'number', tooltip: 'Facing direction in degrees used for directional emit.', range: { min: 0, max: 360, step: 0.1 } }, + emitRange: { valueType: 'number', tooltip: "Maximum distance in squares for this radio's emitted audio.", range: { min: 5, max: 20, step: 1 } }, + }, + dice: { + title: { valueType: 'text', tooltip: 'Display name spoken and shown for this item.' }, + sides: { valueType: 'number', tooltip: 'Number of sides on each die.', range: { min: 1, max: 100, step: 1 } }, + number: { valueType: 'number', tooltip: 'How many dice to roll per use.', range: { min: 1, max: 100, step: 1 } }, + }, + wheel: { + title: { valueType: 'text', tooltip: 'Display name spoken and shown for this item.' }, + spaces: { valueType: 'text', tooltip: 'Comma-delimited list of wheel spaces. Example: yes, no, maybe.' }, + }, + clock: { + title: { valueType: 'text', tooltip: 'Display name spoken and shown for this item.' }, + timeZone: { valueType: 'list', tooltip: 'Timezone used when the clock speaks time.' }, + use24Hour: { valueType: 'boolean', tooltip: 'Use 24 hour format instead of AM/PM.' }, + }, +}; + type UiDefinitionsPayload = { itemTypeOrder?: ItemType[]; itemTypes?: Array<{ type: ItemType; label?: string; + tooltip?: string; editableProperties?: string[]; propertyOptions?: Record; + propertyMetadata?: Record; globalProperties?: Record; }>; }; @@ -79,6 +128,12 @@ let itemTypeLabels: Record = { wheel: 'wheel', clock: 'clock', }; +let itemTypeTooltips: Record = { + radio_station: DEFAULT_ITEM_TYPE_TOOLTIPS.radio_station, + dice: DEFAULT_ITEM_TYPE_TOOLTIPS.dice, + wheel: DEFAULT_ITEM_TYPE_TOOLTIPS.wheel, + clock: DEFAULT_ITEM_TYPE_TOOLTIPS.clock, +}; let itemTypeEditableProperties: Record = { radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station], dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice], @@ -96,6 +151,12 @@ let optionItemPropertyValues: Partial> = { channel: [...RADIO_CHANNEL_OPTIONS], timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS], }; +let itemTypePropertyMetadata: Record> = { + radio_station: { ...DEFAULT_ITEM_PROPERTY_METADATA.radio_station }, + dice: { ...DEFAULT_ITEM_PROPERTY_METADATA.dice }, + wheel: { ...DEFAULT_ITEM_PROPERTY_METADATA.wheel }, + clock: { ...DEFAULT_ITEM_PROPERTY_METADATA.clock }, +}; export let EDITABLE_ITEM_PROPERTY_KEYS = new Set( Object.values(itemTypeEditableProperties).flatMap((keys) => keys), @@ -105,6 +166,38 @@ function rebuildEditablePropertyKeySet(): void { EDITABLE_ITEM_PROPERTY_KEYS = new Set(Object.values(itemTypeEditableProperties).flatMap((keys) => keys)); } +function normalizePropertyMetadataRecord(raw: Record | undefined): Record { + if (!raw) return {}; + const normalized: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!value || typeof value !== 'object') continue; + const valueObj = value as Record; + const metadata: ItemPropertyMetadata = {}; + if (valueObj.valueType === 'boolean' || valueObj.valueType === 'text' || valueObj.valueType === 'number' || valueObj.valueType === 'list' || valueObj.valueType === 'sound') { + metadata.valueType = valueObj.valueType; + } + if (typeof valueObj.tooltip === 'string' && valueObj.tooltip.trim().length > 0) { + metadata.tooltip = valueObj.tooltip.trim(); + } + const range = valueObj.range; + if (range && typeof range === 'object') { + const rangeObj = range as Record; + const min = Number(rangeObj.min); + const max = Number(rangeObj.max); + const step = rangeObj.step === undefined ? undefined : Number(rangeObj.step); + if (Number.isFinite(min) && Number.isFinite(max)) { + metadata.range = { + min, + max, + ...(Number.isFinite(step) ? { step } : {}), + }; + } + } + normalized[key] = metadata; + } + return normalized; +} + export function getClockTimeZoneOptions(): string[] { return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)]; } @@ -121,6 +214,14 @@ export function getItemTypeGlobalProperties(itemType: ItemType): Record> = { ...optionItemPropertyValues }; + const nextPropertyMetadata = { ...itemTypePropertyMetadata }; for (const definition of uiDefinitions.itemTypes) { if (!definition || typeof definition.type !== 'string') continue; @@ -203,9 +306,15 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload if (typeof definition.label === 'string' && definition.label.trim()) { nextLabels[itemType] = definition.label.trim(); } + if (typeof definition.tooltip === 'string' && definition.tooltip.trim()) { + nextTooltips[itemType] = definition.tooltip.trim(); + } if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) { nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string'); } + if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') { + nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata); + } if (definition.globalProperties && typeof definition.globalProperties === 'object') { const normalized: Record = {}; for (const [key, raw] of Object.entries(definition.globalProperties)) { @@ -227,8 +336,10 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload } itemTypeLabels = nextLabels; + itemTypeTooltips = nextTooltips; itemTypeEditableProperties = nextEditable; itemTypeGlobalProperties = nextGlobals; optionItemPropertyValues = nextOptions; + itemTypePropertyMetadata = nextPropertyMetadata; rebuildEditablePropertyKeySet(); } diff --git a/client/src/main.ts b/client/src/main.ts index 5c663b3..dc392c3 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -41,7 +41,9 @@ import { getEditableItemPropertyKeys, getInspectItemPropertyKeys, getItemPropertyOptionValues, + getItemPropertyMetadata, itemPropertyLabel, + getItemTypeTooltip, itemTypeLabel, } from './items/itemRegistry'; import { PeerManager } from './webrtc/peerManager'; @@ -725,6 +727,90 @@ function getItemPropertyValue(item: WorldItem, key: string): string { return String(item.params[key] ?? ''); } +function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined { + if (key === 'useSound' || key === 'emitSound') return 'sound'; + if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean'; + if (key === 'channel' || key === 'effect' || key === 'timeZone') return 'list'; + if ( + key === 'x' || + key === 'y' || + key === 'version' || + key === 'volume' || + key === 'effectValue' || + key === 'facing' || + key === 'emitRange' || + key === 'sides' || + key === 'number' || + key === 'useCooldownMs' + ) { + return 'number'; + } + if (key in item.params || key in getItemTypeGlobalProperties(item.type)) { + const value = item.params[key] ?? getItemTypeGlobalProperties(item.type)?.[key]; + if (typeof value === 'boolean') return 'boolean'; + if (typeof value === 'number') return 'number'; + if (typeof value === 'string') return 'text'; + } + return 'text'; +} + +function describeItemPropertyHelp(item: WorldItem, key: string): string { + const metadata = getItemPropertyMetadata(item.type, key); + const parts: string[] = []; + if (metadata?.tooltip) { + parts.push(metadata.tooltip); + } else { + parts.push('No tooltip available.'); + } + + const valueType = metadata?.valueType ?? inferItemPropertyValueType(item, key); + if (valueType) { + parts.push(`Type: ${valueType}.`); + } + + if (metadata?.range) { + const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : ''; + parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`); + } else { + const options = getItemPropertyOptionValues(key); + if (options && options.length > 0) { + parts.push(`Options: ${options.join(', ')}.`); + } + } + + parts.push(EDITABLE_ITEM_PROPERTY_KEYS.has(key) ? 'Editable.' : 'Read only.'); + return parts.join(' '); +} + +function validateNumericItemPropertyInput( + item: WorldItem, + key: string, + rawValue: string, + requireInteger: boolean, +): { ok: true; value: number } | { ok: false; message: string } { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed)) { + return { ok: false, message: `${itemPropertyLabel(key)} must be a number.` }; + } + if (requireInteger && !Number.isInteger(parsed)) { + return { ok: false, message: `${itemPropertyLabel(key)} must be an integer.` }; + } + const range = getItemPropertyMetadata(item.type, key)?.range; + if (range && (parsed < range.min || parsed > range.max)) { + return { ok: false, message: `${itemPropertyLabel(key)} must be between ${range.min} and ${range.max}.` }; + } + if (!range) { + return { ok: true, value: parsed }; + } + const step = range.step; + if (step && step > 0) { + const normalized = Math.round((parsed - range.min) / step) * step + range.min; + const decimals = step >= 1 ? 0 : Math.ceil(Math.abs(Math.log10(step))); + return { ok: true, value: Number(normalized.toFixed(Math.min(6, decimals + 1))) }; + } + return { ok: true, value: parsed }; +} + function squareWord(distance: number): string { return distance === 1 ? 'square' : 'squares'; } @@ -1770,6 +1856,13 @@ function handleAddItemModeInput(code: string, key: string): void { audio.sfxUiBlip(); return; } + if (code === 'Space') { + const itemType = itemTypeSequence[state.addItemTypeIndex]; + const tooltip = getItemTypeTooltip(itemType); + updateStatus(tooltip ? tooltip : 'No tooltip available.'); + audio.sfxUiBlip(); + return; + } if (code === 'Enter') { signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] }); state.mode = 'normal'; @@ -1888,6 +1981,12 @@ function handleItemPropertiesModeInput(code: string, key: string): void { audio.sfxUiBlip(); return; } + if (code === 'Space') { + const selectedKey = state.itemPropertyKeys[state.itemPropertyIndex]; + updateStatus(describeItemPropertyHelp(item, selectedKey)); + audio.sfxUiBlip(); + return; + } const nextByInitial = findNextIndexByInitial( state.itemPropertyKeys, state.itemPropertyIndex, @@ -1963,6 +2062,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo state.mode = 'normal'; return; } + const item = state.items.get(itemId); + if (!item) { + state.mode = 'normal'; + state.editingPropertyKey = null; + updateStatus('Item no longer exists.'); + audio.sfxUiCancel(); + return; + } if (code === 'Enter') { const value = state.nicknameInput.trim(); if (propertyKey === 'title') { @@ -1984,13 +2091,13 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo const enabled = ['on', 'true', '1', 'yes'].includes(normalized); signaling.send({ type: 'item_update', itemId, params: { enabled } }); } else if (propertyKey === 'volume') { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { - updateStatus('volume must be an integer between 0 and 100.'); + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); + if (!parsed.ok) { + updateStatus(parsed.message); audio.sfxUiCancel(); return; } - signaling.send({ type: 'item_update', itemId, params: { volume: parsed } }); + signaling.send({ type: 'item_update', itemId, params: { volume: parsed.value } }); } else if (propertyKey === 'effect') { const normalized = value.trim().toLowerCase() as EffectId; if (!EFFECT_IDS.has(normalized)) { @@ -2000,29 +2107,29 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo } signaling.send({ type: 'item_update', itemId, params: { effect: normalized } }); } else if (propertyKey === 'effectValue') { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) { - updateStatus('effectValue must be a number between 0 and 100.'); + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false); + if (!parsed.ok) { + updateStatus(parsed.message); audio.sfxUiCancel(); return; } - signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } }); + signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed.value) } }); } else if (propertyKey === 'facing') { - const parsed = Number(value); - if (!Number.isFinite(parsed) || parsed < 0 || parsed > 360) { - updateStatus('facing must be a number between 0 and 360.'); + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false); + if (!parsed.ok) { + updateStatus(parsed.message); audio.sfxUiCancel(); return; } - signaling.send({ type: 'item_update', itemId, params: { facing: Math.round(parsed * 10) / 10 } }); + signaling.send({ type: 'item_update', itemId, params: { facing: parsed.value } }); } else if (propertyKey === 'emitRange') { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 5 || parsed > 20) { - updateStatus('emit range must be an integer between 5 and 20.'); + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); + if (!parsed.ok) { + updateStatus(parsed.message); audio.sfxUiCancel(); return; } - signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed } }); + signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed.value } }); } else if (propertyKey === 'spaces') { const spaces = value .split(',') @@ -2045,13 +2152,13 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo } signaling.send({ type: 'item_update', itemId, params: { spaces: spaces.join(', ') } }); } else if (propertyKey === 'sides' || propertyKey === 'number') { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { - updateStatus(`${propertyKey} must be an integer between 1 and 100.`); + const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); + if (!parsed.ok) { + updateStatus(parsed.message); audio.sfxUiCancel(); return; } - signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: parsed } }); + signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: parsed.value } }); } state.mode = 'itemProperties'; state.editingPropertyKey = null; diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 9d81f54..93eaa5c 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -41,8 +41,25 @@ export const welcomeMessageSchema = z.object({ z.object({ type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), label: z.string().optional(), + tooltip: z.string().optional(), editableProperties: z.array(z.string()), propertyOptions: z.record(z.string(), z.array(z.string())).optional(), + propertyMetadata: z + .record( + z.string(), + z.object({ + valueType: z.enum(['boolean', 'text', 'number', 'list', 'sound']).optional(), + tooltip: z.string().optional(), + range: z + .object({ + min: z.number(), + max: z.number(), + step: z.number().optional(), + }) + .optional(), + }), + ) + .optional(), globalProperties: z.record(z.string(), z.unknown()).optional(), }), ), diff --git a/docs/controls.md b/docs/controls.md index b08e7be..2872c8f 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -61,6 +61,7 @@ Applies to effect select, user/item list modes, item selection, item property li - `ArrowUp` / `ArrowDown`: Move selection - `Enter`: Confirm selection - `Escape`: Exit/cancel +- `Space`: Read tooltip/help for current option (where metadata is available) - First-letter navigation: jump to next matching entry ## Help Viewer Mode diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index 56fc3d6..a6f39ec 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -40,8 +40,10 @@ This is a behavior guide for packet semantics beyond raw schemas. - `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. - `welcome.uiDefinitions`: server-provided item UI definitions: - `itemTypeOrder`: add-item menu order + - `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[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`) - Clients keep local fallback defaults but should prefer server-provided metadata when present. diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index a991ffb..b38294c 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -130,6 +130,68 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = { "timeZone": CLOCK_TIME_ZONE_OPTIONS, } +ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = { + "radio_station": "Streams audio from a URL and can emit directional sound on the grid.", + "dice": "Roll one or more dice and report each value plus total.", + "wheel": "Spin a wheel from a comma-delimited list of spaces.", + "clock": "Speaks the current time using its configured timezone and format.", +} + +ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = { + "radio_station": { + "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."}, + "streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio."}, + "enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."}, + "channel": {"valueType": "list", "tooltip": "Select stereo, mono, left-only, or right-only channel mix."}, + "volume": { + "valueType": "number", + "tooltip": "Playback volume percent for this radio.", + "range": {"min": 0, "max": 100, "step": 1}, + }, + "effect": {"valueType": "list", "tooltip": "Select the active radio effect."}, + "effectValue": { + "valueType": "number", + "tooltip": "Amount for the selected effect.", + "range": {"min": 0, "max": 100, "step": 0.1}, + }, + "facing": { + "valueType": "number", + "tooltip": "Facing direction in degrees used for directional emit.", + "range": {"min": 0, "max": 360, "step": 0.1}, + }, + "emitRange": { + "valueType": "number", + "tooltip": "Maximum distance in squares for this radio's emitted audio.", + "range": {"min": 5, "max": 20, "step": 1}, + }, + }, + "dice": { + "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."}, + "sides": { + "valueType": "number", + "tooltip": "Number of sides on each die.", + "range": {"min": 1, "max": 100, "step": 1}, + }, + "number": { + "valueType": "number", + "tooltip": "How many dice to roll per use.", + "range": {"min": 1, "max": 100, "step": 1}, + }, + }, + "wheel": { + "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."}, + "spaces": { + "valueType": "text", + "tooltip": "Comma-delimited list of wheel spaces. Example: yes, no, maybe.", + }, + }, + "clock": { + "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."}, + "timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."}, + "use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."}, + }, +} + def get_item_definition(item_type: ItemType) -> ItemDefinition: """Return catalog definition for a known item type.""" diff --git a/server/app/server.py b/server/app/server.py index 9bf6755..d32bf07 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -24,7 +24,9 @@ from .item_catalog import ( ITEM_PROPERTY_OPTIONS, ITEM_TYPE_EDITABLE_PROPERTIES, ITEM_TYPE_LABELS, + ITEM_TYPE_PROPERTY_METADATA, ITEM_TYPE_SEQUENCE, + ITEM_TYPE_TOOLTIPS, get_item_global_properties, get_item_use_cooldown_ms, ) @@ -265,8 +267,10 @@ class SignalingServer: { "type": item_type, "label": ITEM_TYPE_LABELS.get(item_type, item_type), + "tooltip": ITEM_TYPE_TOOLTIPS.get(item_type), "editableProperties": editable, "propertyOptions": property_options, + "propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}), "globalProperties": get_item_global_properties(item_type), } )