Add item/property tooltip metadata and schema-driven ranges
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
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 = {
|
||||
itemTypeOrder?: ItemType[];
|
||||
itemTypes?: Array<{
|
||||
type: ItemType;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
editableProperties?: string[];
|
||||
propertyOptions?: Record<string, string[]>;
|
||||
propertyMetadata?: Record<string, unknown>;
|
||||
globalProperties?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
@@ -79,6 +128,12 @@ let itemTypeLabels: Record<ItemType, string> = {
|
||||
wheel: 'wheel',
|
||||
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[]> = {
|
||||
radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station],
|
||||
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
|
||||
@@ -96,6 +151,12 @@ let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
||||
channel: [...RADIO_CHANNEL_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>(
|
||||
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));
|
||||
}
|
||||
|
||||
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[] {
|
||||
return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)];
|
||||
}
|
||||
@@ -121,6 +214,14 @@ export function getItemTypeGlobalProperties(itemType: ItemType): Record<string,
|
||||
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 {
|
||||
return optionItemPropertyValues[key];
|
||||
}
|
||||
@@ -193,9 +294,11 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
||||
}
|
||||
|
||||
const nextLabels = { ...itemTypeLabels };
|
||||
const nextTooltips = { ...itemTypeTooltips };
|
||||
const nextEditable = { ...itemTypeEditableProperties };
|
||||
const nextGlobals = { ...itemTypeGlobalProperties };
|
||||
const nextOptions: Partial<Record<string, string[]>> = { ...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<string, string | number | boolean> = {};
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user