Add item/property tooltip metadata and schema-driven ranges

This commit is contained in:
Jage9
2026-02-21 20:47:02 -05:00
parent 4ddb8ee75f
commit 0656de7485
8 changed files with 325 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -61,13 +61,62 @@ const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, strin
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false }, clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false },
}; };
export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
export type ItemPropertyMetadata = {
valueType?: ItemPropertyValueType;
tooltip?: string;
range?: {
min: number;
max: number;
step?: number;
};
};
const DEFAULT_ITEM_TYPE_TOOLTIPS: Record<ItemType, string> = {
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<ItemType, Record<string, ItemPropertyMetadata>> = {
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 = { type UiDefinitionsPayload = {
itemTypeOrder?: ItemType[]; itemTypeOrder?: ItemType[];
itemTypes?: Array<{ itemTypes?: Array<{
type: ItemType; type: ItemType;
label?: string; label?: string;
tooltip?: string;
editableProperties?: string[]; editableProperties?: string[];
propertyOptions?: Record<string, string[]>; propertyOptions?: Record<string, string[]>;
propertyMetadata?: Record<string, unknown>;
globalProperties?: Record<string, unknown>; globalProperties?: Record<string, unknown>;
}>; }>;
}; };
@@ -79,6 +128,12 @@ let itemTypeLabels: Record<ItemType, string> = {
wheel: 'wheel', wheel: 'wheel',
clock: 'clock', clock: 'clock',
}; };
let itemTypeTooltips: Record<ItemType, string> = {
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<ItemType, string[]> = { let itemTypeEditableProperties: Record<ItemType, string[]> = {
radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station], radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station],
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice], dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
@@ -96,6 +151,12 @@ let optionItemPropertyValues: Partial<Record<string, string[]>> = {
channel: [...RADIO_CHANNEL_OPTIONS], channel: [...RADIO_CHANNEL_OPTIONS],
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS], timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
}; };
let itemTypePropertyMetadata: Record<ItemType, Record<string, ItemPropertyMetadata>> = {
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<string>( export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys), Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
@@ -105,6 +166,38 @@ function rebuildEditablePropertyKeySet(): void {
EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys)); EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys));
} }
function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefined): Record<string, ItemPropertyMetadata> {
if (!raw) return {};
const normalized: Record<string, ItemPropertyMetadata> = {};
for (const [key, value] of Object.entries(raw)) {
if (!value || typeof value !== 'object') continue;
const valueObj = value as Record<string, unknown>;
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<string, unknown>;
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[] { export function getClockTimeZoneOptions(): string[] {
return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)]; return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)];
} }
@@ -121,6 +214,14 @@ export function getItemTypeGlobalProperties(itemType: ItemType): Record<string,
return itemTypeGlobalProperties[itemType] ?? {}; return itemTypeGlobalProperties[itemType] ?? {};
} }
export function getItemTypeTooltip(itemType: ItemType): string | undefined {
return itemTypeTooltips[itemType];
}
export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPropertyMetadata | undefined {
return itemTypePropertyMetadata[itemType]?.[key];
}
export function getItemPropertyOptionValues(key: string): string[] | undefined { export function getItemPropertyOptionValues(key: string): string[] | undefined {
return optionItemPropertyValues[key]; return optionItemPropertyValues[key];
} }
@@ -193,9 +294,11 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
} }
const nextLabels = { ...itemTypeLabels }; const nextLabels = { ...itemTypeLabels };
const nextTooltips = { ...itemTypeTooltips };
const nextEditable = { ...itemTypeEditableProperties }; const nextEditable = { ...itemTypeEditableProperties };
const nextGlobals = { ...itemTypeGlobalProperties }; const nextGlobals = { ...itemTypeGlobalProperties };
const nextOptions: Partial<Record<string, string[]>> = { ...optionItemPropertyValues }; const nextOptions: Partial<Record<string, string[]>> = { ...optionItemPropertyValues };
const nextPropertyMetadata = { ...itemTypePropertyMetadata };
for (const definition of uiDefinitions.itemTypes) { for (const definition of uiDefinitions.itemTypes) {
if (!definition || typeof definition.type !== 'string') continue; if (!definition || typeof definition.type !== 'string') continue;
@@ -203,9 +306,15 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
if (typeof definition.label === 'string' && definition.label.trim()) { if (typeof definition.label === 'string' && definition.label.trim()) {
nextLabels[itemType] = 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) { if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string'); 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') { if (definition.globalProperties && typeof definition.globalProperties === 'object') {
const normalized: Record<string, string | number | boolean> = {}; const normalized: Record<string, string | number | boolean> = {};
for (const [key, raw] of Object.entries(definition.globalProperties)) { for (const [key, raw] of Object.entries(definition.globalProperties)) {
@@ -227,8 +336,10 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
} }
itemTypeLabels = nextLabels; itemTypeLabels = nextLabels;
itemTypeTooltips = nextTooltips;
itemTypeEditableProperties = nextEditable; itemTypeEditableProperties = nextEditable;
itemTypeGlobalProperties = nextGlobals; itemTypeGlobalProperties = nextGlobals;
optionItemPropertyValues = nextOptions; optionItemPropertyValues = nextOptions;
itemTypePropertyMetadata = nextPropertyMetadata;
rebuildEditablePropertyKeySet(); rebuildEditablePropertyKeySet();
} }

View File

@@ -41,7 +41,9 @@ import {
getEditableItemPropertyKeys, getEditableItemPropertyKeys,
getInspectItemPropertyKeys, getInspectItemPropertyKeys,
getItemPropertyOptionValues, getItemPropertyOptionValues,
getItemPropertyMetadata,
itemPropertyLabel, itemPropertyLabel,
getItemTypeTooltip,
itemTypeLabel, itemTypeLabel,
} from './items/itemRegistry'; } from './items/itemRegistry';
import { PeerManager } from './webrtc/peerManager'; import { PeerManager } from './webrtc/peerManager';
@@ -725,6 +727,90 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
return String(item.params[key] ?? ''); 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 { function squareWord(distance: number): string {
return distance === 1 ? 'square' : 'squares'; return distance === 1 ? 'square' : 'squares';
} }
@@ -1770,6 +1856,13 @@ function handleAddItemModeInput(code: string, key: string): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
return; 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') { if (code === 'Enter') {
signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] }); signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] });
state.mode = 'normal'; state.mode = 'normal';
@@ -1888,6 +1981,12 @@ function handleItemPropertiesModeInput(code: string, key: string): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
return; return;
} }
if (code === 'Space') {
const selectedKey = state.itemPropertyKeys[state.itemPropertyIndex];
updateStatus(describeItemPropertyHelp(item, selectedKey));
audio.sfxUiBlip();
return;
}
const nextByInitial = findNextIndexByInitial( const nextByInitial = findNextIndexByInitial(
state.itemPropertyKeys, state.itemPropertyKeys,
state.itemPropertyIndex, state.itemPropertyIndex,
@@ -1963,6 +2062,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
state.mode = 'normal'; state.mode = 'normal';
return; 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') { if (code === 'Enter') {
const value = state.nicknameInput.trim(); const value = state.nicknameInput.trim();
if (propertyKey === 'title') { if (propertyKey === 'title') {
@@ -1984,13 +2091,13 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
const enabled = ['on', 'true', '1', 'yes'].includes(normalized); const enabled = ['on', 'true', '1', 'yes'].includes(normalized);
signaling.send({ type: 'item_update', itemId, params: { enabled } }); signaling.send({ type: 'item_update', itemId, params: { enabled } });
} else if (propertyKey === 'volume') { } else if (propertyKey === 'volume') {
const parsed = Number(value); const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { if (!parsed.ok) {
updateStatus('volume must be an integer between 0 and 100.'); updateStatus(parsed.message);
audio.sfxUiCancel(); audio.sfxUiCancel();
return; return;
} }
signaling.send({ type: 'item_update', itemId, params: { volume: parsed } }); signaling.send({ type: 'item_update', itemId, params: { volume: parsed.value } });
} else if (propertyKey === 'effect') { } else if (propertyKey === 'effect') {
const normalized = value.trim().toLowerCase() as EffectId; const normalized = value.trim().toLowerCase() as EffectId;
if (!EFFECT_IDS.has(normalized)) { 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 } }); signaling.send({ type: 'item_update', itemId, params: { effect: normalized } });
} else if (propertyKey === 'effectValue') { } else if (propertyKey === 'effectValue') {
const parsed = Number(value); const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 100) { if (!parsed.ok) {
updateStatus('effectValue must be a number between 0 and 100.'); updateStatus(parsed.message);
audio.sfxUiCancel(); audio.sfxUiCancel();
return; 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') { } else if (propertyKey === 'facing') {
const parsed = Number(value); const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 360) { if (!parsed.ok) {
updateStatus('facing must be a number between 0 and 360.'); updateStatus(parsed.message);
audio.sfxUiCancel(); audio.sfxUiCancel();
return; 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') { } else if (propertyKey === 'emitRange') {
const parsed = Number(value); const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
if (!Number.isInteger(parsed) || parsed < 5 || parsed > 20) { if (!parsed.ok) {
updateStatus('emit range must be an integer between 5 and 20.'); updateStatus(parsed.message);
audio.sfxUiCancel(); audio.sfxUiCancel();
return; return;
} }
signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed } }); signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed.value } });
} else if (propertyKey === 'spaces') { } else if (propertyKey === 'spaces') {
const spaces = value const spaces = value
.split(',') .split(',')
@@ -2045,13 +2152,13 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
} }
signaling.send({ type: 'item_update', itemId, params: { spaces: spaces.join(', ') } }); signaling.send({ type: 'item_update', itemId, params: { spaces: spaces.join(', ') } });
} else if (propertyKey === 'sides' || propertyKey === 'number') { } else if (propertyKey === 'sides' || propertyKey === 'number') {
const parsed = Number(value); const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { if (!parsed.ok) {
updateStatus(`${propertyKey} must be an integer between 1 and 100.`); updateStatus(parsed.message);
audio.sfxUiCancel(); audio.sfxUiCancel();
return; return;
} }
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: parsed } }); signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: parsed.value } });
} }
state.mode = 'itemProperties'; state.mode = 'itemProperties';
state.editingPropertyKey = null; state.editingPropertyKey = null;

View File

@@ -41,8 +41,25 @@ export const welcomeMessageSchema = z.object({
z.object({ z.object({
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
label: z.string().optional(), label: z.string().optional(),
tooltip: z.string().optional(),
editableProperties: z.array(z.string()), editableProperties: z.array(z.string()),
propertyOptions: z.record(z.string(), z.array(z.string())).optional(), 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(), globalProperties: z.record(z.string(), z.unknown()).optional(),
}), }),
), ),

View File

@@ -61,6 +61,7 @@ Applies to effect select, user/item list modes, item selection, item property li
- `ArrowUp` / `ArrowDown`: Move selection - `ArrowUp` / `ArrowDown`: Move selection
- `Enter`: Confirm selection - `Enter`: Confirm selection
- `Escape`: Exit/cancel - `Escape`: Exit/cancel
- `Space`: Read tooltip/help for current option (where metadata is available)
- First-letter navigation: jump to next matching entry - First-letter navigation: jump to next matching entry
## Help Viewer Mode ## Help Viewer Mode

View File

@@ -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.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing.
- `welcome.uiDefinitions`: server-provided item UI definitions: - `welcome.uiDefinitions`: server-provided item UI definitions:
- `itemTypeOrder`: add-item menu order - `itemTypeOrder`: add-item menu order
- `itemTypes[].tooltip`: item-level tooltip/help text
- `itemTypes[].editableProperties`: editable property keys by item type - `itemTypes[].editableProperties`: editable property keys by item type
- `itemTypes[].propertyOptions`: menu options for property keys (for example clock `timeZone`) - `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`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`)
- Clients keep local fallback defaults but should prefer server-provided metadata when present. - Clients keep local fallback defaults but should prefer server-provided metadata when present.

View File

@@ -130,6 +130,68 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
"timeZone": CLOCK_TIME_ZONE_OPTIONS, "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: def get_item_definition(item_type: ItemType) -> ItemDefinition:
"""Return catalog definition for a known item type.""" """Return catalog definition for a known item type."""

View File

@@ -24,7 +24,9 @@ from .item_catalog import (
ITEM_PROPERTY_OPTIONS, ITEM_PROPERTY_OPTIONS,
ITEM_TYPE_EDITABLE_PROPERTIES, ITEM_TYPE_EDITABLE_PROPERTIES,
ITEM_TYPE_LABELS, ITEM_TYPE_LABELS,
ITEM_TYPE_PROPERTY_METADATA,
ITEM_TYPE_SEQUENCE, ITEM_TYPE_SEQUENCE,
ITEM_TYPE_TOOLTIPS,
get_item_global_properties, get_item_global_properties,
get_item_use_cooldown_ms, get_item_use_cooldown_ms,
) )
@@ -265,8 +267,10 @@ class SignalingServer:
{ {
"type": item_type, "type": item_type,
"label": ITEM_TYPE_LABELS.get(item_type, item_type), "label": ITEM_TYPE_LABELS.get(item_type, item_type),
"tooltip": ITEM_TYPE_TOOLTIPS.get(item_type),
"editableProperties": editable, "editableProperties": editable,
"propertyOptions": property_options, "propertyOptions": property_options,
"propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}),
"globalProperties": get_item_global_properties(item_type), "globalProperties": get_item_global_properties(item_type),
} }
) )