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.
// 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.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -26,16 +26,20 @@ type EditorDeps = {
getItemPropertyOptionValues: (key: string) => string[] | undefined;
openItemPropertyOptionSelect: (item: WorldItem, key: string) => void;
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: (
item: WorldItem,
key: string,
rawValue: string,
requireInteger: boolean,
) => { 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;
setReplaceTextOnNextType: (value: boolean) => void;
suppressItemPropertyEchoMs: (ms: number) => void;
@@ -115,7 +119,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
if (metadata?.valueType === 'boolean') {
let current = item.params[selectedKey];
if (typeof current !== 'boolean') {
current = selectedKey === 'enabled' ? item.params.enabled !== false : item.params[selectedKey] === true;
current = item.params[selectedKey] === true;
}
const nextValue = !current;
deps.suppressItemPropertyEchoMs(600);
@@ -163,24 +167,13 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.sfxUiCancel();
return;
}
if (selectedKey === 'enabled') {
const nextEnabled = item.params.enabled === false;
deps.signalingSend({ type: 'item_update', itemId, params: { enabled: nextEnabled } });
deps.updateStatus(`enabled: ${nextEnabled ? 'on' : 'off'}`);
deps.sfxUiBlip();
return;
}
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'}`);
const metadata = deps.getItemPropertyMetadata(item.type, selectedKey);
if (metadata?.valueType === 'boolean') {
const current = item.params[selectedKey];
const nextValue = typeof current === 'boolean' ? !current : deps.getItemPropertyValue(item, selectedKey).toLowerCase() !== 'on';
deps.signalingSend({ type: 'item_update', itemId, params: { [selectedKey]: nextValue } });
deps.onPreviewPropertyChange?.(item, selectedKey, nextValue);
deps.updateStatus(`${deps.itemPropertyLabel(selectedKey)}: ${nextValue ? 'on' : 'off'}`);
deps.sfxUiBlip();
return;
}
@@ -190,13 +183,14 @@ export function createItemPropertyEditor(deps: EditorDeps): {
}
deps.state.mode = 'itemPropertyEdit';
deps.state.editingPropertyKey = selectedKey;
const selectedMetadata = deps.getItemPropertyMetadata(item.type, selectedKey);
deps.state.nicknameInput =
selectedKey === 'title'
? item.title
: selectedKey === 'enabled'
? item.params.enabled === false
? 'off'
: 'on'
: selectedMetadata?.valueType === 'boolean'
? item.params[selectedKey] === true
? 'on'
: 'off'
: String(item.params[selectedKey] ?? '');
deps.state.cursorPos = deps.state.nicknameInput.length;
deps.setReplaceTextOnNextType(true);
@@ -271,6 +265,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
const editAction = getEditSessionAction(code);
if (editAction === 'submit') {
const value = deps.state.nicknameInput.trim();
const metadata = deps.getItemPropertyMetadata(item.type, propertyKey);
const valueType = metadata?.valueType;
const sendItemParams = (params: Record<string, unknown>): void => {
deps.signalingSend({ type: 'item_update', itemId, 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) };
};
const submitNumericParam = (
targetKey: string,
requireInteger: boolean,
transform?: (num: number) => number,
): boolean => {
const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, requireInteger);
const submitNumericParam = (targetKey: string): boolean => {
const parsed = deps.validateNumericItemPropertyInput(item, targetKey, value, false);
if (!parsed.ok) {
deps.updateStatus(parsed.message);
deps.sfxUiCancel();
return false;
}
sendItemParams({ [targetKey]: transform ? transform(parsed.value) : parsed.value });
sendItemParams({ [targetKey]: parsed.value });
return true;
};
if (propertyKey === 'title') {
@@ -307,62 +299,34 @@ export function createItemPropertyEditor(deps: EditorDeps): {
return;
}
deps.signalingSend({ type: 'item_update', itemId, title: value });
} else if (propertyKey === 'streamUrl') {
sendItemParams({ streamUrl: value });
} else if (propertyKey === 'enabled' || propertyKey === 'directional') {
} else if (valueType === 'boolean') {
const toggle = parseToggleValue(value, propertyKey);
if (!toggle.ok) return;
sendItemParams({ [propertyKey]: toggle.value });
} else if (
propertyKey === 'mediaVolume' ||
propertyKey === 'emitVolume' ||
propertyKey === 'emitRange' ||
propertyKey === 'octave' ||
propertyKey === 'attack' ||
propertyKey === 'decay' ||
propertyKey === 'release' ||
propertyKey === 'brightness' ||
propertyKey === 'sides' ||
propertyKey === 'number'
) {
if (!submitNumericParam(propertyKey, true)) return;
} else if (propertyKey === 'emitSoundSpeed' || propertyKey === 'emitSoundTempo') {
if (!submitNumericParam(propertyKey, false)) return;
} else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') {
const normalized = value.trim().toLowerCase();
if (!deps.effectIds.has(normalized)) {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${deps.effectSequenceIdsCsv}.`);
} else if (valueType === 'number') {
if (!submitNumericParam(propertyKey)) return;
} else if (valueType === 'list') {
const options = deps.getItemPropertyOptionValues(propertyKey) ?? [];
if (options.length === 0) {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`);
deps.sfxUiCancel();
return;
}
const normalized = value.toLowerCase();
const matched = options.find((option) => option.toLowerCase() === normalized);
if (!matched) {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be one of: ${options.join(', ')}.`);
deps.sfxUiCancel();
return;
}
sendItemParams({ [propertyKey]: matched });
} else {
if (metadata?.maxLength !== undefined && value.length > metadata.maxLength) {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} must be ${metadata.maxLength} characters or less.`);
deps.sfxUiCancel();
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 });
} 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.editingPropertyKey = null;
@@ -377,7 +341,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.sfxUiCancel();
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 {

View File

@@ -1,6 +1,4 @@
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';
@@ -8,6 +6,7 @@ export type ItemPropertyMetadata = {
valueType?: ItemPropertyValueType;
tooltip?: string;
maxLength?: number;
visibleWhen?: Record<string, string | number | boolean>;
range?: {
min: number;
max: number;
@@ -27,42 +26,21 @@ type UiDefinitionsPayload = {
globalProperties?: Record<string, unknown>;
}>;
};
let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE];
let itemTypeLabels: Record<ItemType, string> = {} as Record<ItemType, string>;
let itemTypeSequence: ItemType[] = [];
let itemTypeLabels: Partial<Record<ItemType, string>> = {};
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
let itemTypeEditableProperties: Record<ItemType, string[]> = {} as Record<ItemType, string[]>;
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {} as Record<
ItemType,
Record<string, string | number | boolean>
>;
let itemTypeEditableProperties: Partial<Record<ItemType, string[]>> = {};
let itemTypeGlobalProperties: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
let optionItemPropertyValues: Partial<Record<string, string[]>> = {};
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>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
Object.values(itemTypeEditableProperties).flatMap((keys) => keys ?? []),
);
/** Rebuilds the flattened editable-key lookup after item-type definitions are replaced. */
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. */
@@ -85,6 +63,17 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
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;
if (range && typeof range === 'object') {
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. */
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. */
@@ -171,7 +160,11 @@ export function itemPropertyLabel(key: string): string {
/** Returns editable properties for one item instance/type. */
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). */
@@ -201,6 +194,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b));
for (const key of paramKeys) {
if (!isItemPropertyVisible(item, key)) continue;
if (seen.has(key)) continue;
seen.add(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));
for (const key of globalKeys) {
if (!isItemPropertyVisible(item, key)) continue;
if (seen.has(key)) continue;
seen.add(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. */
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void {
if (!uiDefinitions) return;
if (Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0) {
itemTypeSequence = uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[];
}
if (!Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) {
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): boolean {
if (!uiDefinitions || !Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) {
itemTypeSequence = [];
itemTypeLabels = {};
itemTypeTooltips = {};
itemTypeEditableProperties = {};
itemTypeGlobalProperties = {};
optionItemPropertyValues = {};
itemTypePropertyMetadata = {};
rebuildEditablePropertyKeySet();
return;
return false;
}
const nextLabels = { ...itemTypeLabels };
const nextTooltips = { ...itemTypeTooltips };
const nextEditable = { ...itemTypeEditableProperties };
const nextGlobals = { ...itemTypeGlobalProperties };
const nextOptions: Partial<Record<string, string[]>> = { ...optionItemPropertyValues };
const nextPropertyMetadata = { ...itemTypePropertyMetadata };
const explicitOrder =
Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0
? (uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[])
: null;
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) {
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;
itemTypeTooltips = nextTooltips;
itemTypeEditableProperties = nextEditable;
itemTypeGlobalProperties = nextGlobals;
optionItemPropertyValues = nextOptions;
itemTypePropertyMetadata = nextPropertyMetadata;
itemTypeSequence = explicitOrder ?? discoveredOrder;
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 { AudioEngine } from './audio/audioEngine';
import {
EFFECT_IDS,
EFFECT_SEQUENCE,
clampEffectLevel,
} from './audio/effects';
import {
RadioStationRuntime,
@@ -235,6 +233,7 @@ let lastSubscriptionRefreshTileY = Math.round(state.player.y);
let subscriptionRefreshInFlight = false;
let subscriptionRefreshPending = false;
let suppressItemPropertyEchoUntilMs = 0;
let itemPropertiesShowAll = false;
let activeTeleportLoopStop: (() => void) | null = null;
let activeTeleportLoopToken = 0;
let activeTeleport:
@@ -832,6 +831,7 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins
/** Opens item property browsing/editing mode for one item. */
function beginItemProperties(item: WorldItem, showAll = false): void {
itemPropertiesShowAll = showAll;
state.selectedItemId = item.id;
state.mode = 'itemProperties';
state.editingPropertyKey = null;
@@ -843,12 +843,42 @@ function beginItemProperties(item: WorldItem, showAll = false): void {
state.itemPropertyKeys = getEditableItemPropertyKeys(item);
}
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 value = getItemPropertyValue(item, key);
updateStatus(`${itemPropertyLabel(key)}: ${value}`);
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. */
function useItem(item: WorldItem): void {
signaling.send({ type: 'item_use', itemId: item.id });
@@ -1362,6 +1392,7 @@ const onAppMessage = createOnMessageHandler({
audioUiCancel: () => audio.sfxUiCancel(),
NICKNAME_STORAGE_KEY,
getCarriedItemId: () => getCarriedItem()?.id ?? null,
recomputeActiveItemPropertyKeys,
itemPropertyLabel,
getItemPropertyValue,
getItemById: (itemId) => state.items.get(itemId),
@@ -2121,9 +2152,6 @@ const itemPropertyEditor = createItemPropertyEditor({
describeItemPropertyHelp,
getItemPropertyMetadata,
validateNumericItemPropertyInput,
clampEffectLevel,
effectIds: EFFECT_IDS as Set<string>,
effectSequenceIdsCsv: EFFECT_SEQUENCE.map((effect) => effect.id).join(', '),
applyTextInputEdit,
setReplaceTextOnNextType: (value) => {
replaceTextOnNextType = value;

View File

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

View File

@@ -49,8 +49,8 @@
- Persisted state stores only instance data.
- 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`.
- Client-side add/edit metadata is handled in `client/src/items/itemRegistry.ts`.
- 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 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`.
## Type Params
@@ -77,7 +77,8 @@
- `mediaChannel`: one of `stereo | mono | left | right`, default `stereo`.
- `mediaEffect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
- `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`.
### `dice`
@@ -148,7 +149,8 @@
- `enabled`: boolean (or `on/off` in updates), default `true`.
- `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`.
- `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`).

View File

@@ -187,9 +187,9 @@ This is behavior-focused documentation for item types and their defaults.
- `emitRange`: integer `5..20`
- 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`.
@@ -197,13 +197,15 @@ For a full copy/paste example with plain-English explanation, see `docs/item-typ
- defaults/capabilities
- property metadata/options
- `validate_update` and `use_item`
2. Server registry: add one entry in `server/app/items/registry.py`:
- `ITEM_MODULES`
- `ITEM_TYPE_ORDER` (if ordering changes)
2. Server plugin: add `server/app/items/types/<item_type>/plugin.py` exporting `ITEM_TYPE_PLUGIN` with:
- `type`
- `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.
4. Client fallback registry: add type defaults in `client/src/items/itemRegistry.ts` (`DEFAULT_ITEM_TYPE_SEQUENCE`, editable/global fallback metadata).
5. Client protocol/state types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`.
6. Tests: add or update server tests under `server/tests/` for use/update validation and cooldown behavior.
4. 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, unknown-key stripping, and `uiDefinitions` completeness.
### 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[].editableProperties`: editable property keys by item type
- `itemTypes[].propertyOptions`: menu options for property keys (for example clock `timeZone`)
- `itemTypes[].propertyMetadata`: property-level metadata (`valueType`, optional `range`, optional `tooltip`)
- `itemTypes[].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`)
- Clients keep local fallback defaults but should prefer server-provided metadata when present.
- Client item UI requires this metadata from the server; there is no fallback item definition map.
## Validation Boundaries