Add item/property tooltip metadata and schema-driven ranges
This commit is contained in:
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user