refactor: complete server-first item schema wiring and plugin contract checks

This commit is contained in:
Jage9
2026-02-24 18:48:08 -05:00
parent 7776676e2d
commit fcb5e85b13
20 changed files with 132 additions and 69 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 R227"; window.CHGRID_WEB_VERSION = "2026.02.24 R228";
// 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

@@ -23,7 +23,7 @@ type EditorDeps = {
getItemPropertyValue: (item: WorldItem, key: string) => string; getItemPropertyValue: (item: WorldItem, key: string) => string;
itemPropertyLabel: (key: string) => string; itemPropertyLabel: (key: string) => string;
isItemPropertyEditable: (item: WorldItem, key: string) => boolean; 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; openItemPropertyOptionSelect: (item: WorldItem, key: string) => void;
describeItemPropertyHelp: (item: WorldItem, key: string) => string; describeItemPropertyHelp: (item: WorldItem, key: string) => string;
getItemPropertyMetadata: ( getItemPropertyMetadata: (
@@ -98,7 +98,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.sfxUiCancel(); deps.sfxUiCancel();
return; return;
} }
const options = deps.getItemPropertyOptionValues(selectedKey); const options = deps.getItemPropertyOptionValues(item.type, selectedKey);
if (options && options.length > 0) { if (options && options.length > 0) {
const currentRaw = String(item.params[selectedKey] ?? '').trim().toLowerCase(); const currentRaw = String(item.params[selectedKey] ?? '').trim().toLowerCase();
const currentIndex = Math.max( const currentIndex = Math.max(
@@ -177,7 +177,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
deps.sfxUiBlip(); deps.sfxUiBlip();
return; return;
} }
if (deps.getItemPropertyOptionValues(selectedKey)) { if (deps.getItemPropertyOptionValues(item.type, selectedKey)) {
deps.openItemPropertyOptionSelect(item, selectedKey); deps.openItemPropertyOptionSelect(item, selectedKey);
return; return;
} }
@@ -306,7 +306,7 @@ export function createItemPropertyEditor(deps: EditorDeps): {
} else if (valueType === 'number') { } else if (valueType === 'number') {
if (!submitNumericParam(propertyKey)) return; if (!submitNumericParam(propertyKey)) return;
} else if (valueType === 'list') { } else if (valueType === 'list') {
const options = deps.getItemPropertyOptionValues(propertyKey) ?? []; const options = deps.getItemPropertyOptionValues(item.type, propertyKey) ?? [];
if (options.length === 0) { if (options.length === 0) {
deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`); deps.updateStatus(`${deps.itemPropertyLabel(propertyKey)} has no options.`);
deps.sfxUiCancel(); deps.sfxUiCancel();

View File

@@ -153,7 +153,7 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : ''; const stepText = metadata.range.step !== undefined ? ` step ${metadata.range.step}` : '';
parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`); parts.push(`Range: ${metadata.range.min} to ${metadata.range.max}${stepText}.`);
} else { } else {
const options = getItemPropertyOptionValues(key); const options = getItemPropertyOptionValues(item.type, key);
if (options && options.length > 0) { if (options && options.length > 0) {
parts.push(`Options: ${options.join(', ')}.`); parts.push(`Options: ${options.join(', ')}.`);
} }
@@ -205,4 +205,3 @@ export function createItemPropertyPresentation(deps: PresentationDeps): {
validateNumericItemPropertyInput, validateNumericItemPropertyInput,
}; };
} }

View File

@@ -4,8 +4,10 @@ export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'so
export type ItemPropertyMetadata = { export type ItemPropertyMetadata = {
valueType?: ItemPropertyValueType; valueType?: ItemPropertyValueType;
label?: string;
tooltip?: string; tooltip?: string;
maxLength?: number; maxLength?: number;
options?: string[];
visibleWhen?: Record<string, string | number | boolean>; visibleWhen?: Record<string, string | number | boolean>;
range?: { range?: {
min: number; min: number;
@@ -20,8 +22,8 @@ type UiDefinitionsPayload = {
type: ItemType; type: ItemType;
label?: string; label?: string;
tooltip?: string; tooltip?: string;
capabilities?: string[];
editableProperties?: string[]; editableProperties?: string[];
propertyOptions?: Record<string, string[]>;
propertyMetadata?: Record<string, unknown>; propertyMetadata?: Record<string, unknown>;
globalProperties?: Record<string, unknown>; globalProperties?: Record<string, unknown>;
}>; }>;
@@ -30,8 +32,8 @@ let itemTypeSequence: ItemType[] = [];
let itemTypeLabels: Partial<Record<ItemType, string>> = {}; let itemTypeLabels: Partial<Record<ItemType, string>> = {};
let itemTypeTooltips: Partial<Record<ItemType, string>> = {}; let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
let itemTypeEditableProperties: 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 itemTypeGlobalProperties: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
let optionItemPropertyValues: Partial<Record<string, string[]>> = {};
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {}; let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>( 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') { if (valueObj.valueType === 'boolean' || valueObj.valueType === 'text' || valueObj.valueType === 'number' || valueObj.valueType === 'list' || valueObj.valueType === 'sound') {
metadata.valueType = valueObj.valueType; 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) { if (typeof valueObj.tooltip === 'string' && valueObj.tooltip.trim().length > 0) {
metadata.tooltip = valueObj.tooltip.trim(); metadata.tooltip = valueObj.tooltip.trim();
} }
@@ -63,6 +68,12 @@ function normalizePropertyMetadataRecord(raw: Record<string, unknown> | undefine
metadata.maxLength = Math.floor(maxLength); 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') { if (valueObj.visibleWhen && typeof valueObj.visibleWhen === 'object') {
const visibleWhen: Record<string, string | number | boolean> = {}; const visibleWhen: Record<string, string | number | boolean> = {};
for (const [conditionKey, conditionValue] of Object.entries(valueObj.visibleWhen as Record<string, unknown>)) { 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. */ /** Returns current timezone option list used by clock item properties. */
export function getClockTimeZoneOptions(): string[] { export function getClockTimeZoneOptions(): string[] {
return [...(optionItemPropertyValues.timeZone ?? [])]; return [...(getItemPropertyMetadata('clock', 'timeZone')?.options ?? [])];
} }
/** Returns default timezone used by clock items when no override is set. */ /** 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. */ /** Returns option-list values for list-based properties, if defined. */
export function getItemPropertyOptionValues(key: string): string[] | undefined { export function getItemPropertyOptionValues(itemType: ItemType, key: string): string[] | undefined {
return optionItemPropertyValues[key]; return itemTypePropertyMetadata[itemType]?.[key]?.options;
} }
/** Returns human-facing label for an item type. */ /** Returns human-facing label for an item type. */
@@ -133,8 +144,17 @@ export function itemTypeLabel(type: ItemType): string {
return itemTypeLabels[type] ?? type; 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. */ /** Returns human-facing label for a property key. */
export function itemPropertyLabel(key: string): string { 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 === 'use24Hour') return 'use 24 hour format';
if (key === 'emitRange') return 'emit range'; if (key === 'emitRange') return 'emit range';
if (key === 'mediaVolume') return 'media volume'; if (key === 'mediaVolume') return 'media volume';
@@ -218,8 +238,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
itemTypeLabels = {}; itemTypeLabels = {};
itemTypeTooltips = {}; itemTypeTooltips = {};
itemTypeEditableProperties = {}; itemTypeEditableProperties = {};
itemTypeCapabilities = {};
itemTypeGlobalProperties = {}; itemTypeGlobalProperties = {};
optionItemPropertyValues = {};
itemTypePropertyMetadata = {}; itemTypePropertyMetadata = {};
rebuildEditablePropertyKeySet(); rebuildEditablePropertyKeySet();
return false; return false;
@@ -233,8 +253,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
const nextLabels: Partial<Record<ItemType, string>> = {}; const nextLabels: Partial<Record<ItemType, string>> = {};
const nextTooltips: Partial<Record<ItemType, string>> = {}; const nextTooltips: Partial<Record<ItemType, string>> = {};
const nextEditable: 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 nextGlobals: Partial<Record<ItemType, Record<string, string | number | boolean>>> = {};
const nextOptions: Partial<Record<string, string[]>> = {};
const nextPropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {}; const nextPropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
for (const definition of uiDefinitions.itemTypes) { for (const definition of uiDefinitions.itemTypes) {
@@ -249,6 +269,9 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) { if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string'); nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string');
} }
if (Array.isArray(definition.capabilities) && definition.capabilities.length > 0) {
nextCapabilities[itemType] = definition.capabilities.filter((entry) => typeof entry === 'string');
}
if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') { if (definition.propertyMetadata && typeof definition.propertyMetadata === 'object') {
nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata); nextPropertyMetadata[itemType] = normalizePropertyMetadataRecord(definition.propertyMetadata);
} }
@@ -261,15 +284,6 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
} }
nextGlobals[itemType] = normalized; 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[] = []; const discoveredOrder: ItemType[] = [];
@@ -281,8 +295,8 @@ export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload
itemTypeLabels = nextLabels; itemTypeLabels = nextLabels;
itemTypeTooltips = nextTooltips; itemTypeTooltips = nextTooltips;
itemTypeEditableProperties = nextEditable; itemTypeEditableProperties = nextEditable;
itemTypeCapabilities = nextCapabilities;
itemTypeGlobalProperties = nextGlobals; itemTypeGlobalProperties = nextGlobals;
optionItemPropertyValues = nextOptions;
itemTypePropertyMetadata = nextPropertyMetadata; itemTypePropertyMetadata = nextPropertyMetadata;
itemTypeSequence = explicitOrder ?? discoveredOrder; itemTypeSequence = explicitOrder ?? discoveredOrder;
rebuildEditablePropertyKeySet(); rebuildEditablePropertyKeySet();

View File

@@ -886,7 +886,7 @@ function useItem(item: WorldItem): void {
/** Opens option-list selection mode for list-based item properties. */ /** Opens option-list selection mode for list-based item properties. */
function openItemPropertyOptionSelect(item: WorldItem, key: string): void { function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
const options = getItemPropertyOptionValues(key); const options = getItemPropertyOptionValues(item.type, key);
if (!options || options.length === 0) { if (!options || options.length === 0) {
return; return;
} }

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
export const itemSchema = z.object({ export const itemSchema = z.object({
id: z.string(), id: z.string(),
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']), type: z.string().min(1),
title: z.string(), title: z.string(),
x: z.number().int(), x: z.number().int(),
y: z.number().int(), y: z.number().int(),
@@ -42,21 +42,24 @@ export const welcomeMessageSchema = z.object({
.optional(), .optional(),
uiDefinitions: z uiDefinitions: z
.object({ .object({
itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano'])), itemTypeOrder: z.array(z.string().min(1)),
itemTypes: z.array( itemTypes: z.array(
z.object({ z.object({
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget', 'piano']), type: z.string().min(1),
label: z.string().optional(), label: z.string().optional(),
tooltip: z.string().optional(), tooltip: z.string().optional(),
editableProperties: z.array(z.string()), editableProperties: z.array(z.string()),
propertyOptions: z.record(z.string(), z.array(z.string())).optional(), capabilities: z.array(z.string()).optional(),
propertyMetadata: z propertyMetadata: z
.record( .record(
z.string(), z.string(),
z.object({ z.object({
valueType: z.enum(['boolean', 'text', 'number', 'list', 'sound']).optional(), valueType: z.enum(['boolean', 'text', 'number', 'list', 'sound']).optional(),
label: z.string().optional(),
tooltip: z.string().optional(), tooltip: z.string().optional(),
maxLength: z.number().int().positive().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 range: z
.object({ .object({
min: z.number(), min: z.number(),
@@ -193,7 +196,7 @@ export type OutgoingMessage =
| { type: 'update_nickname'; nickname: string } | { type: 'update_nickname'; nickname: string }
| { type: 'chat_message'; message: string } | { type: 'chat_message'; message: string }
| { type: 'ping'; clientSentAt: number } | { 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_pickup'; itemId: string }
| { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_drop'; itemId: string; x: number; y: number }
| { type: 'item_delete'; itemId: string } | { type: 'item_delete'; itemId: string }

View File

@@ -2,7 +2,7 @@ export const GRID_SIZE = 41;
export const HEARING_RADIUS = 20; export const HEARING_RADIUS = 20;
export const MOVE_COOLDOWN_MS = 200; export const MOVE_COOLDOWN_MS = 200;
export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' | 'piano'; export type ItemType = string;
export type WorldItem = { export type WorldItem = {
id: string; id: string;

View File

@@ -51,9 +51,9 @@ This is a behavior guide for packet semantics beyond raw schemas.
- `welcome.uiDefinitions`: server-provided item UI definitions: - `welcome.uiDefinitions`: server-provided item UI definitions:
- `itemTypeOrder`: add-item menu order - `itemTypeOrder`: add-item menu order
- `itemTypes[].tooltip`: item-level tooltip/help text - `itemTypes[].tooltip`: item-level tooltip/help text
- `itemTypes[].capabilities`: server-declared actions supported by the type
- `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[].propertyMetadata`: property-level metadata (`valueType`, optional `label`, optional `range`, optional `tooltip`, optional `maxLength`, optional `options`, optional `visibleWhen`)
- `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. - Client item UI requires this metadata from the server; there is no fallback item definition map.

View File

@@ -338,7 +338,7 @@ Implementation:
- Keep compile-time unions only where they materially improve safety and can be generated/centralized. - Keep compile-time unions only where they materially improve safety and can be generated/centralized.
Acceptance criteria: Acceptance criteria:
- Adding a new item plugin does not require editing multiple type-literal lists in unrelated files. - Adding a new item plugin does not require editing multiple type-literal lists in unrelated files.
- New type appears in add/edit flows after server metadata update with minimal client changes (or none for generic items). - New type appears in add/edit flows after server metadata update with minimal client changes.
3. **Include `capabilities` in `welcome.uiDefinitions.itemTypes` (medium).** 3. **Include `capabilities` in `welcome.uiDefinitions.itemTypes` (medium).**
Scope: Scope:
@@ -360,8 +360,7 @@ Problem today:
Implementation: Implementation:
- Server emits list options at `propertyMetadata[key].options`. - Server emits list options at `propertyMetadata[key].options`.
- Client reads options from metadata first. - Client reads options from metadata first.
- Deprecate/remove separate `propertyOptions` map after migration. - Remove separate `propertyOptions` map.
- Keep transition shim only during one deploy window if needed.
Acceptance criteria: Acceptance criteria:
- One canonical place (`propertyMetadata`) defines type, range, tooltip, options, and visibility for each property. - One canonical place (`propertyMetadata`) defines type, range, tooltip, options, and visibility for each property.
- New list property requires server-only metadata changes for options. - New list property requires server-only metadata changes for options.

View File

@@ -3,11 +3,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal, cast from typing import TypeAlias, cast
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] ItemType: TypeAlias = str
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER) ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE} ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = { ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
@@ -75,15 +75,6 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
for item_type in ITEM_TYPE_SEQUENCE for item_type in ITEM_TYPE_SEQUENCE
} }
ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
"mediaEffect": RADIO_EFFECT_OPTIONS,
"emitEffect": RADIO_EFFECT_OPTIONS,
"mediaChannel": RADIO_CHANNEL_OPTIONS,
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
"instrument": PIANO_INSTRUMENT_OPTIONS,
"voiceMode": PIANO_VOICE_MODE_OPTIONS,
}
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = { ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
item_type: ITEM_MODULES[item_type].TOOLTIP for item_type in ITEM_TYPE_SEQUENCE item_type: ITEM_MODULES[item_type].TOOLTIP for item_type in ITEM_TYPE_SEQUENCE
} }
@@ -125,6 +116,12 @@ def get_item_definition(item_type: ItemType) -> ItemDefinition:
return ITEM_DEFINITIONS[item_type] return ITEM_DEFINITIONS[item_type]
def is_known_item_type(item_type: str) -> bool:
"""Return whether a string item type id exists in discovered plugins."""
return item_type in ITEM_DEFINITIONS
def get_item_use_cooldown_ms(item_type: ItemType) -> int: def get_item_use_cooldown_ms(item_type: ItemType) -> int:
"""Return validated global use cooldown in milliseconds for an item type.""" """Return validated global use cooldown in milliseconds for an item type."""

View File

@@ -8,8 +8,6 @@ import time
import uuid import uuid
from copy import deepcopy from copy import deepcopy
from pathlib import Path from pathlib import Path
from typing import Literal
from .client import ClientConnection from .client import ClientConnection
from .item_catalog import get_item_definition from .item_catalog import get_item_definition
from .models import PersistedWorldItem, WorldItem from .models import PersistedWorldItem, WorldItem
@@ -36,7 +34,7 @@ class ItemService:
return int(time.time() * 1000) return int(time.time() * 1000)
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem: def default_item(self, client: ClientConnection, item_type: str) -> WorldItem:
"""Create a new server-authoritative item at the caller's position.""" """Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type) item_def = get_item_definition(item_type)

View File

@@ -60,6 +60,6 @@ PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
PROPERTY_METADATA: dict[str, dict[str, object]] = { PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80}, "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."}, "timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time.", "options": list(TIME_ZONE_OPTIONS)},
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."}, "use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
} }

View File

@@ -64,8 +64,8 @@ DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] =
PROPERTY_METADATA: dict[str, dict[str, object]] = { PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80}, "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."}, "instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano.", "options": list(INSTRUMENT_OPTIONS)},
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."}, "voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords.", "options": list(VOICE_MODE_OPTIONS)},
"octave": { "octave": {
"valueType": "number", "valueType": "number",
"tooltip": "Shifts played notes in octaves. -1 is one octave down.", "tooltip": "Shifts played notes in octaves. -1 is one octave down.",

View File

@@ -55,8 +55,8 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
"tooltip": "Playback media volume percent for this radio.", "tooltip": "Playback media volume percent for this radio.",
"range": {"min": 0, "max": 100, "step": 1}, "range": {"min": 0, "max": 100, "step": 1},
}, },
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."}, "mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered.", "options": list(CHANNEL_OPTIONS)},
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."}, "mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect.", "options": list(EFFECT_OPTIONS)},
"mediaEffectValue": { "mediaEffectValue": {
"valueType": "number", "valueType": "number",
"tooltip": "Amount for the selected effect.", "tooltip": "Amount for the selected effect.",

View File

@@ -83,7 +83,7 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
"tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.", "tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
"range": {"min": 0, "max": 100, "step": 0.1}, "range": {"min": 0, "max": 100, "step": 0.1},
}, },
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."}, "emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound.", "options": list(EFFECT_OPTIONS)},
"emitEffectValue": { "emitEffectValue": {
"valueType": "number", "valueType": "number",
"tooltip": "Amount for emit effect.", "tooltip": "Amount for emit effect.",

View File

@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
class ItemAddPacket(BasePacket): class ItemAddPacket(BasePacket):
type: Literal["item_add"] type: Literal["item_add"]
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] itemType: str = Field(min_length=1)
class ItemPickupPacket(BasePacket): class ItemPickupPacket(BasePacket):
@@ -173,7 +173,7 @@ class NicknameResultPacket(BasePacket):
class WorldItem(BaseModel): class WorldItem(BaseModel):
id: str id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] type: str = Field(min_length=1)
title: str title: str
x: int x: int
y: int y: int
@@ -191,7 +191,7 @@ class WorldItem(BaseModel):
class PersistedWorldItem(BaseModel): class PersistedWorldItem(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"] type: str = Field(min_length=1)
title: str title: str
x: int x: int
y: int y: int

View File

@@ -25,14 +25,15 @@ from .config import load_config
from .item_catalog import ( from .item_catalog import (
CLOCK_DEFAULT_TIME_ZONE, CLOCK_DEFAULT_TIME_ZONE,
CLOCK_TIME_ZONE_OPTIONS, CLOCK_TIME_ZONE_OPTIONS,
ITEM_PROPERTY_OPTIONS,
ITEM_TYPE_EDITABLE_PROPERTIES, ITEM_TYPE_EDITABLE_PROPERTIES,
ITEM_TYPE_LABELS, ITEM_TYPE_LABELS,
ITEM_TYPE_PROPERTY_METADATA, ITEM_TYPE_PROPERTY_METADATA,
ITEM_TYPE_SEQUENCE, ITEM_TYPE_SEQUENCE,
ITEM_TYPE_TOOLTIPS, ITEM_TYPE_TOOLTIPS,
get_item_definition,
get_item_global_properties, get_item_global_properties,
get_item_use_cooldown_ms, get_item_use_cooldown_ms,
is_known_item_type,
) )
from .item_type_handlers import get_item_type_handler from .item_type_handlers import get_item_type_handler
from .item_service import ItemService from .item_service import ItemService
@@ -708,18 +709,13 @@ class SignalingServer:
item_types: list[dict] = [] item_types: list[dict] = []
for item_type in ITEM_TYPE_SEQUENCE: for item_type in ITEM_TYPE_SEQUENCE:
editable = list(ITEM_TYPE_EDITABLE_PROPERTIES.get(item_type, ("title",))) editable = list(ITEM_TYPE_EDITABLE_PROPERTIES.get(item_type, ("title",)))
property_options: dict[str, list[str]] = {}
for key in editable:
options = ITEM_PROPERTY_OPTIONS.get(key)
if options:
property_options[key] = list(options)
item_types.append( item_types.append(
{ {
"type": item_type, "type": item_type,
"label": ITEM_TYPE_LABELS.get(item_type, item_type), "label": ITEM_TYPE_LABELS.get(item_type, item_type),
"tooltip": ITEM_TYPE_TOOLTIPS.get(item_type), "tooltip": ITEM_TYPE_TOOLTIPS.get(item_type),
"capabilities": list(get_item_definition(item_type).capabilities),
"editableProperties": editable, "editableProperties": editable,
"propertyOptions": property_options,
"propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}), "propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}),
"globalProperties": get_item_global_properties(item_type), "globalProperties": get_item_global_properties(item_type),
} }
@@ -897,6 +893,9 @@ class SignalingServer:
return return
if isinstance(packet, ItemAddPacket): if isinstance(packet, ItemAddPacket):
if not is_known_item_type(packet.itemType):
await self._send_item_result(client, False, "add", "Unknown item type.")
return
item = self.item_service.default_item(client, packet.itemType) item = self.item_service.default_item(client, packet.itemType)
self.item_service.add_item(item) self.item_service.add_item(item)
await self._broadcast_item(item) await self._broadcast_item(item)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
from app.items.registry import ITEM_PLUGINS
def test_item_plugins_expose_expected_contract() -> None:
for plugin in ITEM_PLUGINS:
module = plugin.module
assert isinstance(plugin.type, str) and plugin.type
assert isinstance(plugin.order, int)
assert callable(getattr(module, "validate_update", None))
assert callable(getattr(module, "use_item", None))
assert isinstance(getattr(module, "LABEL", ""), str)
assert isinstance(getattr(module, "TOOLTIP", ""), str)
assert isinstance(getattr(module, "EDITABLE_PROPERTIES", ()), tuple)
assert isinstance(getattr(module, "CAPABILITIES", ()), tuple)
assert isinstance(getattr(module, "DEFAULT_PARAMS", {}), dict)
assert isinstance(getattr(module, "PROPERTY_METADATA", {}), dict)
def test_item_plugin_folders_have_required_files() -> None:
base_dir = Path(__file__).resolve().parents[1] / "app" / "items" / "types"
for plugin in ITEM_PLUGINS:
type_dir = base_dir / plugin.type
assert type_dir.is_dir()
assert (type_dir / "definition.py").is_file()
assert (type_dir / "validator.py").is_file()
assert (type_dir / "actions.py").is_file()
assert (type_dir / "module.py").is_file()
assert (type_dir / "plugin.py").is_file()

View File

@@ -33,15 +33,16 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
assert isinstance(entry.get("type"), str) assert isinstance(entry.get("type"), str)
assert isinstance(entry.get("label"), str) assert isinstance(entry.get("label"), str)
assert isinstance(entry.get("editableProperties"), list) assert isinstance(entry.get("editableProperties"), list)
assert isinstance(entry.get("capabilities"), list)
assert isinstance(entry.get("propertyMetadata"), dict) assert isinstance(entry.get("propertyMetadata"), dict)
assert isinstance(entry.get("propertyOptions"), dict)
assert isinstance(entry.get("globalProperties"), dict) assert isinstance(entry.get("globalProperties"), dict)
editable_properties = entry["editableProperties"] editable_properties = entry["editableProperties"]
capabilities = entry["capabilities"]
property_metadata = entry["propertyMetadata"] property_metadata = entry["propertyMetadata"]
property_options = entry["propertyOptions"]
global_properties = entry["globalProperties"] global_properties = entry["globalProperties"]
assert capabilities
assert required_global_keys.issubset(set(global_properties.keys())) assert required_global_keys.issubset(set(global_properties.keys()))
for property_key in editable_properties: for property_key in editable_properties:
if property_key == "title": if property_key == "title":
@@ -50,7 +51,7 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
metadata = property_metadata[property_key] metadata = property_metadata[property_key]
assert isinstance(metadata, dict) assert isinstance(metadata, dict)
if metadata.get("valueType") == "list": if metadata.get("valueType") == "list":
options = property_options.get(property_key) options = metadata.get("options")
assert isinstance(options, list) assert isinstance(options, list)
assert options assert options

View File

@@ -83,3 +83,24 @@ async def test_broadcast_fanout_is_concurrent(monkeypatch: pytest.MonkeyPatch) -
assert ws1 in send_started_at assert ws1 in send_started_at
assert ws2 in send_started_at assert ws2 in send_started_at
assert abs(send_started_at[ws1] - send_started_at[ws2]) < 0.02 assert abs(send_started_at[ws1] - send_started_at[ws2]) < 0.02
@pytest.mark.asyncio
async def test_item_add_rejects_unknown_type(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)
ws = _fake_ws()
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
server.clients[ws] = client
send_payloads: list[object] = []
async def fake_send(websocket: ServerConnection, packet: object) -> None:
send_payloads.append(packet)
monkeypatch.setattr(server, "_send", fake_send)
await server._handle_message(client, json.dumps({"type": "item_add", "itemType": "not_a_type"}))
assert send_payloads
assert send_payloads[-1].ok is False
assert "unknown item type" in send_payloads[-1].message.lower()