refactor: complete server-first item schema wiring and plugin contract checks
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Maintainer-controlled web client version.
|
||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||
window.CHGRID_WEB_VERSION = "2026.02.24 R227";
|
||||
window.CHGRID_WEB_VERSION = "2026.02.24 R228";
|
||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||
|
||||
@@ -23,7 +23,7 @@ type EditorDeps = {
|
||||
getItemPropertyValue: (item: WorldItem, key: string) => string;
|
||||
itemPropertyLabel: (key: string) => string;
|
||||
isItemPropertyEditable: (item: WorldItem, key: string) => boolean;
|
||||
getItemPropertyOptionValues: (key: string) => string[] | undefined;
|
||||
getItemPropertyOptionValues: (itemType: WorldItem['type'], key: string) => string[] | undefined;
|
||||
openItemPropertyOptionSelect: (item: WorldItem, key: string) => void;
|
||||
describeItemPropertyHelp: (item: WorldItem, key: string) => string;
|
||||
getItemPropertyMetadata: (
|
||||
@@ -98,7 +98,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
deps.sfxUiCancel();
|
||||
return;
|
||||
}
|
||||
const options = deps.getItemPropertyOptionValues(selectedKey);
|
||||
const options = deps.getItemPropertyOptionValues(item.type, selectedKey);
|
||||
if (options && options.length > 0) {
|
||||
const currentRaw = String(item.params[selectedKey] ?? '').trim().toLowerCase();
|
||||
const currentIndex = Math.max(
|
||||
@@ -177,7 +177,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
deps.sfxUiBlip();
|
||||
return;
|
||||
}
|
||||
if (deps.getItemPropertyOptionValues(selectedKey)) {
|
||||
if (deps.getItemPropertyOptionValues(item.type, selectedKey)) {
|
||||
deps.openItemPropertyOptionSelect(item, selectedKey);
|
||||
return;
|
||||
}
|
||||
@@ -306,7 +306,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
||||
} else if (valueType === 'number') {
|
||||
if (!submitNumericParam(propertyKey)) return;
|
||||
} else if (valueType === 'list') {
|
||||
const options = deps.getItemPropertyOptionValues(propertyKey) ?? [];
|
||||
const options = deps.getItemPropertyOptionValues(item.type, propertyKey) ?? [];
|
||||
if (options.length === 0) {
|
||||
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`);
|
||||
deps.sfxUiCancel();
|
||||
|
||||
@@ -153,7 +153,7 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
|
||||
const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : '';
|
||||
parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`);
|
||||
} else {
|
||||
const options = getItemPropertyOptionValues(key);
|
||||
const options = getItemPropertyOptionValues(item.type, key);
|
||||
if (options && options.length > 0) {
|
||||
parts.push(`Options: ${options.join(', ')}.`);
|
||||
}
|
||||
@@ -205,4 +205,3 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
|
||||
validateNumericItemPropertyInput,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,10 @@ export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'so
|
||||
|
||||
export type ItemPropertyMetadata = {
|
||||
valueType?: ItemPropertyValueType;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
maxLength?: number;
|
||||
options?: string[];
|
||||
visibleWhen?: Record<string, string | number | boolean>;
|
||||
range?: {
|
||||
min: number;
|
||||
@@ -20,8 +22,8 @@ type UiDefinitionsPayload = {
|
||||
type: ItemType;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
capabilities?: string[];
|
||||
editableProperties?: string[];
|
||||
propertyOptions?: Record<string, string[]>;
|
||||
propertyMetadata?: Record<string, unknown>;
|
||||
globalProperties?: Record<string, unknown>;
|
||||
}>;
|
||||
@@ -30,8 +32,8 @@ let itemTypeSequence: ItemType[] = [];
|
||||
let itemTypeLabels: Partial<Record<ItemType, string>> = {};
|
||||
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
||||
let itemTypeEditableProperties: Partial<Record<ItemType, string[]>> = {};
|
||||
let itemTypeCapabilities: 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>>> = {};
|
||||
|
||||
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
|
||||
@@ -54,6 +56,9 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
|
||||
if (valueObj.valueType === 'boolean' || valueObj.valueType === 'text' || valueObj.valueType === 'number' || valueObj.valueType === 'list' || valueObj.valueType === 'sound') {
|
||||
metadata.valueType = valueObj.valueType;
|
||||
}
|
||||
if (typeof valueObj.label === 'string' && valueObj.label.trim().length > 0) {
|
||||
metadata.label = valueObj.label.trim();
|
||||
}
|
||||
if (typeof valueObj.tooltip === 'string' && valueObj.tooltip.trim().length > 0) {
|
||||
metadata.tooltip = valueObj.tooltip.trim();
|
||||
}
|
||||
@@ -63,6 +68,12 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
|
||||
metadata.maxLength = Math.floor(maxLength);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(valueObj.options)) {
|
||||
const options = valueObj.options.filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0);
|
||||
if (options.length > 0) {
|
||||
metadata.options = options;
|
||||
}
|
||||
}
|
||||
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>)) {
|
||||
@@ -95,7 +106,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 ?? [])];
|
||||
return [...(getItemPropertyMetadata('clock', 'timeZone')?.options ?? [])];
|
||||
}
|
||||
|
||||
/** Returns default timezone used by clock items when no override is set. */
|
||||
@@ -124,8 +135,8 @@ export function getItemPropertyMetadata(itemType: ItemType, key: string): ItemPr
|
||||
}
|
||||
|
||||
/** Returns option-list values for list-based properties, if defined. */
|
||||
export function getItemPropertyOptionValues(key: string): string[] | undefined {
|
||||
return optionItemPropertyValues[key];
|
||||
export function getItemPropertyOptionValues(itemType: ItemType, key: string): string[] | undefined {
|
||||
return itemTypePropertyMetadata[itemType]?.[key]?.options;
|
||||
}
|
||||
|
||||
/** Returns human-facing label for an item type. */
|
||||
@@ -133,8 +144,17 @@ export function itemTypeLabel(type: ItemType): string {
|
||||
return itemTypeLabels[type] ?? type;
|
||||
}
|
||||
|
||||
/** Returns server-defined capabilities for one item type, if provided. */
|
||||
export function getItemTypeCapabilities(itemType: ItemType): string[] {
|
||||
return [...(itemTypeCapabilities[itemType] ?? [])];
|
||||
}
|
||||
|
||||
/** Returns human-facing label for a property key. */
|
||||
export function itemPropertyLabel(key: string): string {
|
||||
const metadataLabel = Object.values(itemTypePropertyMetadata)
|
||||
.map((entry) => entry?.[key]?.label)
|
||||
.find((label) => typeof label === 'string' && label.trim().length > 0);
|
||||
if (metadataLabel) return metadataLabel;
|
||||
if (key === 'use24Hour') return 'use 24 hour format';
|
||||
if (key === 'emitRange') return 'emit range';
|
||||
if (key === 'mediaVolume') return 'media volume';
|
||||
@@ -218,8 +238,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
||||
itemTypeLabels = {};
|
||||
itemTypeTooltips = {};
|
||||
itemTypeEditableProperties = {};
|
||||
itemTypeCapabilities = {};
|
||||
itemTypeGlobalProperties = {};
|
||||
optionItemPropertyValues = {};
|
||||
itemTypePropertyMetadata = {};
|
||||
rebuildEditablePropertyKeySet();
|
||||
return false;
|
||||
@@ -233,8 +253,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
||||
const nextLabels: Partial<Record<ItemType, string>> = {};
|
||||
const nextTooltips: Partial<Record<ItemType, string>> = {};
|
||||
const nextEditable: Partial<Record<ItemType, string[]>> = {};
|
||||
const nextCapabilities: 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) {
|
||||
@@ -249,6 +269,9 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
||||
if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
|
||||
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string');
|
||||
}
|
||||
if (Array.isArray(definition.capabilities) && definition.capabilities.length > 0) {
|
||||
nextCapabilities[itemType] = definition.capabilities.filter((entry) => typeof entry === 'string');
|
||||
}
|
||||
if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') {
|
||||
nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata);
|
||||
}
|
||||
@@ -261,15 +284,6 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
||||
}
|
||||
nextGlobals[itemType] = normalized;
|
||||
}
|
||||
if (definition.propertyOptions && typeof definition.propertyOptions === 'object') {
|
||||
for (const [propertyKey, values] of Object.entries(definition.propertyOptions)) {
|
||||
if (!Array.isArray(values) || values.length === 0) continue;
|
||||
const normalizedValues = values.filter((entry) => typeof entry === 'string');
|
||||
if (normalizedValues.length > 0) {
|
||||
nextOptions[propertyKey] = normalizedValues;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const discoveredOrder: ItemType[] = [];
|
||||
@@ -281,8 +295,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
|
||||
itemTypeLabels = nextLabels;
|
||||
itemTypeTooltips = nextTooltips;
|
||||
itemTypeEditableProperties = nextEditable;
|
||||
itemTypeCapabilities = nextCapabilities;
|
||||
itemTypeGlobalProperties = nextGlobals;
|
||||
optionItemPropertyValues = nextOptions;
|
||||
itemTypePropertyMetadata = nextPropertyMetadata;
|
||||
itemTypeSequence = explicitOrder ?? discoveredOrder;
|
||||
rebuildEditablePropertyKeySet();
|
||||
|
||||
@@ -886,7 +886,7 @@ function useItem(item: WorldItem): void {
|
||||
|
||||
/** Opens option-list selection mode for list-based item properties. */
|
||||
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
||||
const options = getItemPropertyOptionValues(key);
|
||||
const options = getItemPropertyOptionValues(item.type, key);
|
||||
if (!options || options.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
||||
|
||||
export const itemSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']),
|
||||
type: z.string().min(1),
|
||||
title: z.string(),
|
||||
x: z.number().int(),
|
||||
y: z.number().int(),
|
||||
@@ -42,21 +42,24 @@ export const welcomeMessageSchema = z.object({
|
||||
.optional(),
|
||||
uiDefinitions: z
|
||||
.object({
|
||||
itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano'])),
|
||||
itemTypeOrder: z.array(z.string().min(1)),
|
||||
itemTypes: z.array(
|
||||
z.object({
|
||||
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']),
|
||||
type: z.string().min(1),
|
||||
label: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
editableProperties: z.array(z.string()),
|
||||
propertyOptions: z.record(z.string(), z.array(z.string())).optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
propertyMetadata: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
valueType: z.enum(['boolean', 'text', 'number', 'list', 'sound']).optional(),
|
||||
label: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
maxLength: z.number().int().positive().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
visibleWhen: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional(),
|
||||
range: z
|
||||
.object({
|
||||
min: z.number(),
|
||||
@@ -193,7 +196,7 @@ export type OutgoingMessage =
|
||||
| { type: 'update_nickname'; nickname: string }
|
||||
| { type: 'chat_message'; message: string }
|
||||
| { type: 'ping'; clientSentAt: number }
|
||||
| { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano' }
|
||||
| { type: 'item_add'; itemType: string }
|
||||
| { type: 'item_pickup'; itemId: string }
|
||||
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
||||
| { type: 'item_delete'; itemId: string }
|
||||
|
||||
@@ -2,7 +2,7 @@ export const GRID_SIZE = 41;
|
||||
export const HEARING_RADIUS = 20;
|
||||
export const MOVE_COOLDOWN_MS = 200;
|
||||
|
||||
export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano';
|
||||
export type ItemType = string;
|
||||
|
||||
export type WorldItem = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user