client: require server item schema and drive property UI from metadata
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.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";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user