client: require server item schema and drive property UI from metadata

This commit is contained in:
Jage9
2026-02-24 02:49:17 -05:00
parent 477b4d2cf4
commit f7e29ec968
8 changed files with 183 additions and 153 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.24 R226"; window.CHGRID_WEB_VERSION = "2026.02.24 R227";
// 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

@@ -26,16 +26,20 @@ type EditorDeps = {
getItemPropertyOptionValues: (key: string) => string[] | undefined; getItemPropertyOptionValues: (key: string) => string[] | undefined;
openItemPropertyOptionSelect: (item: WorldItem, key: string) => void; openItemPropertyOptionSelect: (item: WorldItem, key: string) => void;
describeItemPropertyHelp: (item: WorldItem, key: string) => string; describeItemPropertyHelp: (item: WorldItem, key: string) => string;
getItemPropertyMetadata: (itemType: WorldItem['type'], key: string) => { valueType?: string; range?: { min: number; max: number; step?: number } } | undefined; getItemPropertyMetadata: (
itemType: WorldItem['type'],
key: string,
) => {
valueType?: string;
maxLength?: number;
range?: { min: number; max: number; step?: number };
} | undefined;
validateNumericItemPropertyInput: ( validateNumericItemPropertyInput: (
item: WorldItem, item: WorldItem,
key: string, key: string,
rawValue: string, rawValue: string,
requireInteger: boolean, requireInteger: boolean,
) => { ok: true; value: number } | { ok: false; message: string }; ) => { ok: true; value: number } | { ok: false; message: string };
clampEffectLevel: (value: number) => number;
effectIds: Set<string>;
effectSequenceIdsCsv: string;
applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean, allowReplaceOnNextType?: boolean) => void; applyTextInputEdit: (code: string, key: string, maxLength: number, ctrlKey?: boolean, allowReplaceOnNextType?: boolean) => void;
setReplaceTextOnNextType: (value: boolean) => void; setReplaceTextOnNextType: (value: boolean) => void;
suppressItemPropertyEchoMs: (ms: number) => void; suppressItemPropertyEchoMs: (ms: number) => void;
@@ -115,7 +119,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
if (metadata?.valueType === 'boolean') { if (metadata?.valueType === 'boolean') {
let current = item.params[selectedKey]; let current = item.params[selectedKey];
if (typeof current !== 'boolean') { if (typeof current !== 'boolean') {
current = selectedKey === 'enabled' ? item.params.enabled !== false : item.params[selectedKey] === true; current = item.params[selectedKey] === true;
} }
const nextValue = !current; const nextValue = !current;
deps.suppressItemPropertyEchoMs(600); deps.suppressItemPropertyEchoMs(600);
@@ -163,24 +167,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.sfxUiCancel(); deps.sfxUiCancel();
return; return;
} }
if (selectedKey === 'enabled') { const metadata = deps.getItemPropertyMetadata(item.type, selectedKey);
const nextEnabled = item.params.enabled === false; if (metadata?.valueType === 'boolean') {
deps.signalingSend({ type: 'item_update', itemId, params: { enabled: nextEnabled } }); const current = item.params[selectedKey];
deps.updateStatus(`enabled: ${nextEnabled ? 'on' : 'off'}`); const nextValue = typeof current === 'boolean' ? !current : deps.getItemPropertyValue(item, selectedKey).toLowerCase() !== 'on';
deps.sfxUiBlip(); deps.signalingSend({ type: 'item_update', itemId, params: { [selectedKey]: nextValue } });
return; deps.onPreviewPropertyChange?.(item, selectedKey, nextValue);
} deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${nextValue ? 'on' : 'off'}`);
if (selectedKey === 'directional') {
const nextDirectional = item.params.directional !== true;
deps.signalingSend({ type: 'item_update', itemId, params: { directional: nextDirectional } });
deps.updateStatus(`directional: ${nextDirectional ? 'on' : 'off'}`);
deps.sfxUiBlip();
return;
}
if (selectedKey === 'use24Hour') {
const nextUse24Hour = item.params.use24Hour !== true;
deps.signalingSend({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } });
deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${nextUse24Hour ? 'on' : 'off'}`);
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
@@ -190,13 +183,14 @@ export function createItemPropertyEditor(deps: EditorDeps): {
} }
deps.state.mode = 'itemPropertyEdit'; deps.state.mode = 'itemPropertyEdit';
deps.state.editingPropertyKey = selectedKey; deps.state.editingPropertyKey = selectedKey;
const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
deps.state.nicknameInput = deps.state.nicknameInput =
selectedKey === 'title' selectedKey === 'title'
? item.title ? item.title
: selectedKey === 'enabled' : selectedMetadata?.valueType === 'boolean'
? item.params.enabled === false ? item.params[selectedKey] === true
? 'off' ? 'on'
: 'on' : 'off'
: String(item.params[selectedKey] ?? ''); : String(item.params[selectedKey] ?? '');
deps.state.cursorPos = deps.state.nicknameInput.length; deps.state.cursorPos = deps.state.nicknameInput.length;
deps.setReplaceTextOnNextType(true); deps.setReplaceTextOnNextType(true);
@@ -271,6 +265,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
const editAction = getEditSessionAction(code); const editAction = getEditSessionAction(code);
if (editAction === 'submit') { if (editAction === 'submit') {
const value = deps.state.nicknameInput.trim(); const value = deps.state.nicknameInput.trim();
const metadata = deps.getItemPropertyMetadata(item.type, propertyKey);
const valueType = metadata?.valueType;
const sendItemParams = (params: Record<string, unknown>): void => { const sendItemParams = (params: Record<string, unknown>): void => {
deps.signalingSend({ type: 'item_update', itemId, params }); deps.signalingSend({ type: 'item_update', itemId, params });
for (const [key, nextValue] of Object.entries(params)) { for (const [key, nextValue] of Object.entries(params)) {
@@ -286,18 +282,14 @@ export function createItemPropertyEditor(deps: EditorDeps): {
} }
return { ok: true, value: ['on', 'true', '1', 'yes'].includes(normalized) }; return { ok: true, value: ['on', 'true', '1', 'yes'].includes(normalized) };
}; };
const submitNumericParam = ( const submitNumericParam = (targetKey: string): boolean => {
targetKey: string, const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, false);
requireInteger: boolean,
transform?: (num: number) => number,
): boolean => {
const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, requireInteger);
if (!parsed.ok) { if (!parsed.ok) {
deps.updateStatus(parsed.message); deps.updateStatus(parsed.message);
deps.sfxUiCancel(); deps.sfxUiCancel();
return false; return false;
} }
sendItemParams({ [targetKey]: transform ? transform(parsed.value) : parsed.value }); sendItemParams({ [targetKey]: parsed.value });
return true; return true;
}; };
if (propertyKey === 'title') { if (propertyKey === 'title') {
@@ -307,62 +299,34 @@ export function createItemPropertyEditor(deps: EditorDeps): {
return; return;
} }
deps.signalingSend({ type: 'item_update', itemId, title: value }); deps.signalingSend({ type: 'item_update', itemId, title: value });
} else if (propertyKey === 'streamUrl') { } else if (valueType === 'boolean') {
sendItemParams({ streamUrl: value });
} else if (propertyKey === 'enabled' || propertyKey === 'directional') {
const toggle = parseToggleValue(value, propertyKey); const toggle = parseToggleValue(value, propertyKey);
if (!toggle.ok) return; if (!toggle.ok) return;
sendItemParams({ [propertyKey]: toggle.value }); sendItemParams({ [propertyKey]: toggle.value });
} else if ( } else if (valueType === 'number') {
propertyKey === 'mediaVolume' || if (!submitNumericParam(propertyKey)) return;
propertyKey === 'emitVolume' || } else if (valueType === 'list') {
propertyKey === 'emitRange' || const options = deps.getItemPropertyOptionValues(propertyKey) ?? [];
propertyKey === 'octave' || if (options.length === 0) {
propertyKey === 'attack' || deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`);
propertyKey === 'decay' || deps.sfxUiCancel();
propertyKey === 'release' || return;
propertyKey === 'brightness' || }
propertyKey === 'sides' || const normalized = value.toLowerCase();
propertyKey === 'number' const matched = options.find((option) => option.toLowerCase() === normalized);
) { if (!matched) {
if (!submitNumericParam(propertyKey, true)) return; deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${options.join(', ')}.`);
} else if (propertyKey === 'emitSoundSpeed' || propertyKey === 'emitSoundTempo') { deps.sfxUiCancel();
if (!submitNumericParam(propertyKey, false)) return; return;
} else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') { }
const normalized = value.trim().toLowerCase(); sendItemParams({ [propertyKey]: matched });
if (!deps.effectIds.has(normalized)) { } else {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${deps.effectSequenceIdsCsv}.`); if (metadata?.maxLength !== undefined && value.length > metadata.maxLength) {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be ${metadata.maxLength} characters or less.`);
deps.sfxUiCancel(); deps.sfxUiCancel();
return; return;
} }
sendItemParams({ [propertyKey]: normalized });
} else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') {
if (!submitNumericParam(propertyKey, false, (num) => deps.clampEffectLevel(num))) return;
} else if (propertyKey === 'facing') {
if (!submitNumericParam(propertyKey, false)) return;
} else if (propertyKey === 'useSound' || propertyKey === 'emitSound') {
sendItemParams({ [propertyKey]: value }); sendItemParams({ [propertyKey]: value });
} else if (propertyKey === 'spaces') {
const spaces = value
.split(',')
.map((token) => token.trim())
.filter((token) => token.length > 0);
if (spaces.length === 0) {
deps.updateStatus('spaces must include at least one comma-delimited value.');
deps.sfxUiCancel();
return;
}
if (spaces.length > 100) {
deps.updateStatus('spaces supports up to 100 values.');
deps.sfxUiCancel();
return;
}
if (spaces.some((token) => token.length > 80)) {
deps.updateStatus('each space must be 80 chars or less.');
deps.sfxUiCancel();
return;
}
sendItemParams({ spaces: spaces.join(', ') });
} }
deps.state.mode = 'itemProperties'; deps.state.mode = 'itemProperties';
deps.state.editingPropertyKey = null; deps.state.editingPropertyKey = null;
@@ -377,7 +341,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.sfxUiCancel(); deps.sfxUiCancel();
return; return;
} }
deps.applyTextInputEdit(code, key, 500, ctrlKey, true); const maxLength = deps.getItemPropertyMetadata(item.type, propertyKey)?.maxLength ?? 500;
deps.applyTextInputEdit(code, key, maxLength, ctrlKey, true);
} }
function handleItemPropertyOptionSelectModeInput(code: string, key: string): void { function handleItemPropertyOptionSelectModeInput(code: string, key: string): void {

View File

@@ -1,6 +1,4 @@
import { type ItemType, type WorldItem } from '../state/gameState'; import { type ItemType, type WorldItem } from '../state/gameState';
import { CLOCK_TIME_ZONE_OPTIONS } from './types/clock';
import { DEFAULT_ITEM_TYPE_DEFINITIONS, DEFAULT_ITEM_TYPE_SEQUENCE } from './types';
export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound'; export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
@@ -8,6 +6,7 @@ export type ItemPropertyMetadata = {
valueType?: ItemPropertyValueType; valueType?: ItemPropertyValueType;
tooltip?: string; tooltip?: string;
maxLength?: number; maxLength?: number;
visibleWhen?: Record<string, string | number | boolean>;
range?: { range?: {
min: number; min: number;
max: number; max: number;
@@ -27,42 +26,21 @@ type UiDefinitionsPayload = {
globalProperties?: Record<string, unknown>; globalProperties?: Record<string, unknown>;
}>; }>;
}; };
let itemTypeSequence: ItemType[] = [];
let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE]; let itemTypeLabels: Partial<Record<ItemType, string>> = {};
let itemTypeLabels: Record<ItemType, string> = {} as Record<ItemType, string>;
let itemTypeTooltips: Partial<Record<ItemType, string>> = {}; let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
let itemTypeEditableProperties: Record<ItemType, string[]> = {} as Record<ItemType, string[]>; let itemTypeEditableProperties: Partial<Record<ItemType, string[]>> = {};
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {} as Record< let itemTypeGlobalProperties: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
ItemType,
Record<string, string | number | boolean>
>;
let optionItemPropertyValues: Partial<Record<string, string[]>> = {}; let optionItemPropertyValues: Partial<Record<string, string[]>> = {};
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {}; let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
for (const definition of DEFAULT_ITEM_TYPE_DEFINITIONS) {
itemTypeLabels[definition.type] = definition.label;
if (definition.tooltip) {
itemTypeTooltips[definition.type] = definition.tooltip;
}
itemTypeEditableProperties[definition.type] = [...definition.editableProperties];
itemTypeGlobalProperties[definition.type] = { ...definition.globalProperties };
if (definition.propertyMetadata) {
itemTypePropertyMetadata[definition.type] = { ...definition.propertyMetadata };
}
if (definition.propertyOptions) {
for (const [key, values] of Object.entries(definition.propertyOptions)) {
optionItemPropertyValues[key] = [...values];
}
}
}
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>( export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys), Object.values(itemTypeEditableProperties).flatMap((keys) => keys ?? []),
); );
/** Rebuilds the flattened editable-key lookup after item-type definitions are replaced. */ /** Rebuilds the flattened editable-key lookup after item-type definitions are replaced. */
function rebuildEditablePropertyKeySet(): void { 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 ?? []));
} }
/** Normalizes server-provided property metadata into strict client metadata shape. */ /** Normalizes server-provided property metadata into strict client metadata shape. */
@@ -85,6 +63,17 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
metadata.maxLength = Math.floor(maxLength); metadata.maxLength = Math.floor(maxLength);
} }
} }
if (valueObj.visibleWhen && typeof valueObj.visibleWhen === 'object') {
const visibleWhen: Record<string, string | number | boolean> = {};
for (const [conditionKey, conditionValue] of Object.entries(valueObj.visibleWhen as Record<string, unknown>)) {
if (typeof conditionValue === 'string' || typeof conditionValue === 'number' || typeof conditionValue === 'boolean') {
visibleWhen[conditionKey] = conditionValue;
}
}
if (Object.keys(visibleWhen).length > 0) {
metadata.visibleWhen = visibleWhen;
}
}
const range = valueObj.range; const range = valueObj.range;
if (range && typeof range === 'object') { if (range && typeof range === 'object') {
const rangeObj = range as Record<string, unknown>; const rangeObj = range as Record<string, unknown>;
@@ -106,7 +95,7 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
/** Returns current timezone option list used by clock item properties. */ /** Returns current timezone option list used by clock item properties. */
export function getClockTimeZoneOptions(): string[] { export function getClockTimeZoneOptions(): string[] {
return [...(optionItemPropertyValues.timeZone ?? CLOCK_TIME_ZONE_OPTIONS)]; return [...(optionItemPropertyValues.timeZone ?? [])];
} }
/** Returns default timezone used by clock items when no override is set. */ /** Returns default timezone used by clock items when no override is set. */
@@ -171,7 +160,11 @@ export function itemPropertyLabel(key: string): string {
/** Returns editable properties for one item instance/type. */ /** Returns editable properties for one item instance/type. */
export function getEditableItemPropertyKeys(item: WorldItem): string[] { export function getEditableItemPropertyKeys(item: WorldItem): string[] {
return [...(itemTypeEditableProperties[item.type] ?? ['title'])]; const rawKeys = itemTypeEditableProperties[item.type];
if (!rawKeys || rawKeys.length === 0) {
return [];
}
return rawKeys.filter((key) => isItemPropertyVisible(item, key));
} }
/** Returns inspect-mode property key list (editable first, then system/global extras). */ /** Returns inspect-mode property key list (editable first, then system/global extras). */
@@ -201,6 +194,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b)); const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b));
for (const key of paramKeys) { for (const key of paramKeys) {
if (!isItemPropertyVisible(item, key)) continue;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
allKeys.push(key); allKeys.push(key);
@@ -208,6 +202,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
const globalKeys = Object.keys(itemTypeGlobalProperties[item.type] ?? {}).sort((a, b) => a.localeCompare(b)); const globalKeys = Object.keys(itemTypeGlobalProperties[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
for (const key of globalKeys) { for (const key of globalKeys) {
if (!isItemPropertyVisible(item, key)) continue;
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
allKeys.push(key); allKeys.push(key);
@@ -217,24 +212,30 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
} }
/** Applies server-supplied UI/catalog definitions for item types, properties, and options. */ /** Applies server-supplied UI/catalog definitions for item types, properties, and options. */
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void { export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): boolean {
if (!uiDefinitions) return; if (!uiDefinitions || !Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) {
itemTypeSequence = [];
if (Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0) { itemTypeLabels = {};
itemTypeSequence = uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[]; itemTypeTooltips = {};
} itemTypeEditableProperties = {};
itemTypeGlobalProperties = {};
if (!Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) { optionItemPropertyValues = {};
itemTypePropertyMetadata = {};
rebuildEditablePropertyKeySet(); rebuildEditablePropertyKeySet();
return; return false;
} }
const nextLabels = { ...itemTypeLabels }; const explicitOrder =
const nextTooltips = { ...itemTypeTooltips }; Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0
const nextEditable = { ...itemTypeEditableProperties }; ? (uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[])
const nextGlobals = { ...itemTypeGlobalProperties }; : null;
const nextOptions: Partial<Record<string, string[]>> = { ...optionItemPropertyValues };
const nextPropertyMetadata = { ...itemTypePropertyMetadata }; const nextLabels: Partial<Record<ItemType, string>> = {};
const nextTooltips: Partial<Record<ItemType, string>> = {};
const nextEditable: Partial<Record<ItemType, string[]>> = {};
const nextGlobals: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
const nextOptions: Partial<Record<string, string[]>> = {};
const nextPropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
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;
@@ -271,11 +272,38 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
} }
} }
const discoveredOrder: ItemType[] = [];
for (const definition of uiDefinitions.itemTypes) {
if (!definition || typeof definition.type !== 'string') continue;
discoveredOrder.push(definition.type as ItemType);
}
itemTypeLabels = nextLabels; itemTypeLabels = nextLabels;
itemTypeTooltips = nextTooltips; itemTypeTooltips = nextTooltips;
itemTypeEditableProperties = nextEditable; itemTypeEditableProperties = nextEditable;
itemTypeGlobalProperties = nextGlobals; itemTypeGlobalProperties = nextGlobals;
optionItemPropertyValues = nextOptions; optionItemPropertyValues = nextOptions;
itemTypePropertyMetadata = nextPropertyMetadata; itemTypePropertyMetadata = nextPropertyMetadata;
itemTypeSequence = explicitOrder ?? discoveredOrder;
rebuildEditablePropertyKeySet(); rebuildEditablePropertyKeySet();
return itemTypeSequence.length > 0;
}
/** Returns whether a property is currently visible for an item based on metadata visibility rules. */
export function isItemPropertyVisible(item: WorldItem, key: string): boolean {
const metadata = getItemPropertyMetadata(item.type, key);
const visibilityRule = (metadata as Record<string, unknown> | undefined)?.visibleWhen;
if (!visibilityRule || typeof visibilityRule !== 'object') {
return true;
}
const conditions = visibilityRule as Record<string, string | number | boolean>;
for (const [conditionKey, expected] of Object.entries(conditions)) {
const actual =
item.params[conditionKey] ??
getItemTypeGlobalProperties(item.type)[conditionKey];
if (actual !== expected) {
return false;
}
}
return true;
} }

View File

@@ -1,9 +1,7 @@
import './styles.css'; import './styles.css';
import { AudioEngine } from './audio/audioEngine'; import { AudioEngine } from './audio/audioEngine';
import { import {
EFFECT_IDS,
EFFECT_SEQUENCE, EFFECT_SEQUENCE,
clampEffectLevel,
} from './audio/effects'; } from './audio/effects';
import { import {
RadioStationRuntime, RadioStationRuntime,
@@ -235,6 +233,7 @@ let lastSubscriptionRefreshTileY = Math.round(state.player.y);
let subscriptionRefreshInFlight = false; let subscriptionRefreshInFlight = false;
let subscriptionRefreshPending = false; let subscriptionRefreshPending = false;
let suppressItemPropertyEchoUntilMs = 0; let suppressItemPropertyEchoUntilMs = 0;
let itemPropertiesShowAll = false;
let activeTeleportLoopStop: (() => void) | null = null; let activeTeleportLoopStop: (() => void) | null = null;
let activeTeleportLoopToken = 0; let activeTeleportLoopToken = 0;
let activeTeleport: let activeTeleport:
@@ -832,6 +831,7 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins
/** Opens item property browsing/editing mode for one item. */ /** Opens item property browsing/editing mode for one item. */
function beginItemProperties(item: WorldItem, showAll = false): void { function beginItemProperties(item: WorldItem, showAll = false): void {
itemPropertiesShowAll = showAll;
state.selectedItemId = item.id; state.selectedItemId = item.id;
state.mode = 'itemProperties'; state.mode = 'itemProperties';
state.editingPropertyKey = null; state.editingPropertyKey = null;
@@ -843,12 +843,42 @@ function beginItemProperties(item: WorldItem, showAll = false): void {
state.itemPropertyKeys = getEditableItemPropertyKeys(item); state.itemPropertyKeys = getEditableItemPropertyKeys(item);
} }
state.itemPropertyIndex = 0; state.itemPropertyIndex = 0;
if (state.itemPropertyKeys.length === 0) {
updateStatus('No properties available.');
audio.sfxUiCancel();
state.mode = 'normal';
state.selectedItemId = null;
return;
}
const key = state.itemPropertyKeys[0]; const key = state.itemPropertyKeys[0];
const value = getItemPropertyValue(item, key); const value = getItemPropertyValue(item, key);
updateStatus(`${itemPropertyLabel(key)}: ${value}`); updateStatus(`${itemPropertyLabel(key)}: ${value}`);
audio.sfxUiBlip(); audio.sfxUiBlip();
} }
/** Recomputes visible property rows for the active item-property view after item updates. */
function recomputeActiveItemPropertyKeys(itemId: string): void {
if (state.mode !== 'itemProperties' || state.selectedItemId !== itemId) {
return;
}
const item = state.items.get(itemId);
if (!item) {
return;
}
const previousKey = state.itemPropertyKeys[state.itemPropertyIndex] ?? null;
const nextKeys = itemPropertiesShowAll ? getInspectItemPropertyKeys(item) : getEditableItemPropertyKeys(item);
state.itemPropertyKeys = nextKeys;
if (nextKeys.length === 0) {
state.itemPropertyIndex = 0;
return;
}
if (previousKey && nextKeys.includes(previousKey)) {
state.itemPropertyIndex = nextKeys.indexOf(previousKey);
return;
}
state.itemPropertyIndex = Math.max(0, Math.min(state.itemPropertyIndex, nextKeys.length - 1));
}
/** Sends an item-use request for the selected item. */ /** Sends an item-use request for the selected item. */
function useItem(item: WorldItem): void { function useItem(item: WorldItem): void {
signaling.send({ type: 'item_use', itemId: item.id }); signaling.send({ type: 'item_use', itemId: item.id });
@@ -1362,6 +1392,7 @@ const onAppMessage = createOnMessageHandler({
audioUiCancel: () => audio.sfxUiCancel(), audioUiCancel: () => audio.sfxUiCancel(),
NICKNAME_STORAGE_KEY, NICKNAME_STORAGE_KEY,
getCarriedItemId: () => getCarriedItem()?.id ?? null, getCarriedItemId: () => getCarriedItem()?.id ?? null,
recomputeActiveItemPropertyKeys,
itemPropertyLabel, itemPropertyLabel,
getItemPropertyValue, getItemPropertyValue,
getItemById: (itemId) => state.items.get(itemId), getItemById: (itemId) => state.items.get(itemId),
@@ -2121,9 +2152,6 @@ const itemPropertyEditor = createItemPropertyEditor({
describeItemPropertyHelp, describeItemPropertyHelp,
getItemPropertyMetadata, getItemPropertyMetadata,
validateNumericItemPropertyInput, validateNumericItemPropertyInput,
clampEffectLevel,
effectIds: EFFECT_IDS as Set<string>,
effectSequenceIdsCsv: EFFECT_SEQUENCE.map((effect) => effect.id).join(', '),
applyTextInputEdit, applyTextInputEdit,
setReplaceTextOnNextType: (value) => { setReplaceTextOnNextType: (value) => {
replaceTextOnNextType = value; replaceTextOnNextType = value;

View File

@@ -9,7 +9,7 @@ type MessageHandlerDeps = {
setWorldGridSize: (size: number) => void; setWorldGridSize: (size: number) => void;
setConnecting: (value: boolean) => void; setConnecting: (value: boolean) => void;
rendererSetGridSize: (size: number) => void; rendererSetGridSize: (size: number) => void;
applyServerItemUiDefinitions: (defs: unknown) => void; applyServerItemUiDefinitions: (defs: unknown) => boolean;
state: { state: {
addItemTypeIndex: number; addItemTypeIndex: number;
player: { id: string | null; nickname: string; x: number; y: number }; player: { id: string | null; nickname: string; x: number; y: number };
@@ -62,6 +62,7 @@ type MessageHandlerDeps = {
audioUiCancel: () => void; audioUiCancel: () => void;
NICKNAME_STORAGE_KEY: string; NICKNAME_STORAGE_KEY: string;
getCarriedItemId: () => string | null; getCarriedItemId: () => string | null;
recomputeActiveItemPropertyKeys: (itemId: string) => void;
itemPropertyLabel: (key: string) => string; itemPropertyLabel: (key: string) => string;
getItemPropertyValue: (item: WorldItem, key: string) => string; getItemPropertyValue: (item: WorldItem, key: string) => string;
getItemById: (itemId: string) => WorldItem | undefined; getItemById: (itemId: string) => WorldItem | undefined;
@@ -82,7 +83,11 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
deps.setWorldGridSize(message.worldConfig.gridSize); deps.setWorldGridSize(message.worldConfig.gridSize);
} }
deps.rendererSetGridSize(deps.getWorldGridSize()); deps.rendererSetGridSize(deps.getWorldGridSize());
deps.applyServerItemUiDefinitions(message.uiDefinitions); const schemaReady = deps.applyServerItemUiDefinitions(message.uiDefinitions);
if (!schemaReady) {
deps.updateStatus('Item schema missing from server. Item menus unavailable.');
deps.audioUiCancel();
}
deps.state.addItemTypeIndex = 0; deps.state.addItemTypeIndex = 0;
deps.state.player.id = message.id; deps.state.player.id = message.id;
deps.state.running = true; deps.state.running = true;
@@ -207,6 +212,7 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
carrierId: message.item.carrierId ?? null, carrierId: message.item.carrierId ?? null,
}); });
deps.state.carriedItemId = deps.getCarriedItemId(); deps.state.carriedItemId = deps.getCarriedItemId();
deps.recomputeActiveItemPropertyKeys(message.item.id);
if (deps.state.mode === 'itemProperties' && deps.state.selectedItemId === message.item.id) { if (deps.state.mode === 'itemProperties' && deps.state.selectedItemId === message.item.id) {
const key = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex]; const key = deps.state.itemPropertyKeys[deps.state.itemPropertyIndex];
if (key && deps.shouldAnnounceItemPropertyEcho()) { if (key && deps.shouldAnnounceItemPropertyEcho()) {

View File

@@ -49,8 +49,8 @@
- Persisted state stores only instance data. - Persisted state stores only instance data.
- Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`. - Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`.
- Per-type use/update validation and message behavior are implemented in per-item modules under `server/app/items/` and wired in `server/app/items/registry.py`. - Per-type use/update validation and message behavior are implemented in per-item modules under `server/app/items/`, discovered via plugins in `server/app/items/types/*/plugin.py`.
- Client-side add/edit metadata is handled in `client/src/items/itemRegistry.ts`. - Client-side add/edit metadata is consumed from `welcome.uiDefinitions` via `client/src/items/itemRegistry.ts` (no local fallback definitions).
- End-to-end add-item template: `docs/item-type-template.md`. - End-to-end add-item template: `docs/item-type-template.md`.
## Type Params ## Type Params
@@ -77,7 +77,8 @@
- `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`. - `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`.
- `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
- `mediaEffectValue`: number, range `0-100`, precision `0.1`. - `mediaEffectValue`: number, range `0-100`, precision `0.1`.
- `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`). - `facing`: number, range `0-360`, step `1` (used when `directional=true`).
- UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata).
- `emitRange`: integer, range `5-20`, default `20`. - `emitRange`: integer, range `5-20`, default `20`.
### `dice` ### `dice`
@@ -148,7 +149,8 @@
- `enabled`: boolean (or `on/off` in updates), default `true`. - `enabled`: boolean (or `on/off` in updates), default `true`.
- `directional`: boolean (or `on/off` in updates), default `false`. - `directional`: boolean (or `on/off` in updates), default `false`.
- `facing`: number, range `0-360`, precision `0.1`. - `facing`: number, range `0-360`, step `1`.
- UI visibility: `facing` is shown only when `directional=true` (`visibleWhen` metadata).
- `emitRange`: integer, range `1-20`, default `15`. - `emitRange`: integer, range `1-20`, default `15`.
- `emitVolume`: integer, range `0-100`, default `100`. - `emitVolume`: integer, range `0-100`, default `100`.
- `emitSoundSpeed`: integer, range `0-100`, default `50`; controls emitted sound speed/pitch (`0=0.5x`, `50=1.0x`, `100=2.0x`). - `emitSoundSpeed`: integer, range `0-100`, default `50`; controls emitted sound speed/pitch (`0=0.5x`, `50=1.0x`, `100=2.0x`).

View File

@@ -187,9 +187,9 @@ This is behavior-focused documentation for item types and their defaults.
- `emitRange`: integer `5..20` - `emitRange`: integer `5..20`
- Instrument changes reset `voiceMode`/`octave`/`attack`/`decay`/`release`/`brightness` to instrument defaults. - Instrument changes reset `voiceMode`/`octave`/`attack`/`decay`/`release`/`brightness` to instrument defaults.
## Adding A New Item Type (Registry V1) ## Adding A New Item Type (Plugin Discovery)
Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry. Server is the source of truth for item type definitions and metadata. The client consumes server `welcome.uiDefinitions` and only provides UX/runtime behavior.
For a full copy/paste example with plain-English explanation, see `docs/item-type-template.md`. For a full copy/paste example with plain-English explanation, see `docs/item-type-template.md`.
@@ -197,13 +197,15 @@ For a full copy/paste example with plain-English explanation, see `docs/item-typ
- defaults/capabilities - defaults/capabilities
- property metadata/options - property metadata/options
- `validate_update` and `use_item` - `validate_update` and `use_item`
2. Server registry: add one entry in `server/app/items/registry.py`: 2. Server plugin: add `server/app/items/types/<item_type>/plugin.py` exporting `ITEM_TYPE_PLUGIN` with:
- `ITEM_MODULES` - `type`
- `ITEM_TYPE_ORDER` (if ordering changes) - `order`
- `module`
The server auto-discovers plugins at boot, so no central registry edit is needed.
3. Server models: extend `ItemType` literals in `server/app/models.py` and any packet enums that list item types. 3. Server models: extend `ItemType` literals in `server/app/models.py` and any packet enums that list item types.
4. Client fallback registry: add type defaults in `client/src/items/itemRegistry.ts` (`DEFAULT_ITEM_TYPE_SEQUENCE`, editable/global fallback metadata). 4. Client protocol/state types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`.
5. Client protocol/state types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`. 5. Client runtime behavior: add `client/src/items/types/<item_type>/behavior.ts` only if custom client runtime is needed.
6. Tests: add or update server tests under `server/tests/` for use/update validation and cooldown behavior. 6. Tests: add or update server tests under `server/tests/` for use/update validation, unknown-key stripping, and `uiDefinitions` completeness.
### Example Shape ### Example Shape

View File

@@ -53,10 +53,9 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `itemTypes[].tooltip`: item-level tooltip/help text - `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[].propertyMetadata`: property-level metadata (`valueType`, optional `range`, optional `tooltip`, optional `maxLength`, optional `visibleWhen`)
- `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`) - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`, `emitRange`, `directional`, `emitSoundSpeed`, `emitSoundTempo`)
- Client item UI requires this metadata from the server; there is no fallback item definition map.
- Clients keep local fallback defaults but should prefer server-provided metadata when present.
## Validation Boundaries ## Validation Boundaries