From 008de60727cde6917ceb0f5813af83c082f3c020 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 19:12:58 -0500 Subject: [PATCH] Send world/item UI metadata in welcome and consume on client --- client/src/items/itemRegistry.ts | 140 +++++++++++++++++++++++----- client/src/main.ts | 56 +++++++---- client/src/network/protocol.ts | 19 ++++ client/src/render/canvasRenderer.ts | 14 ++- docs/protocol-notes.md | 13 ++- docs/runtime-flow.md | 2 + server/app/item_catalog.py | 32 +++++++ server/app/models.py | 2 + server/app/server.py | 38 +++++++- 9 files changed, 274 insertions(+), 42 deletions(-) diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 4f03d75..8a450e9 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -2,7 +2,7 @@ import { EFFECT_SEQUENCE } from '../audio/effects'; import { RADIO_CHANNEL_OPTIONS } from '../audio/radioStationRuntime'; import { type ItemType, type WorldItem } from '../state/gameState'; -export const CLOCK_TIME_ZONE_OPTIONS = [ +const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [ 'America/Anchorage', 'America/Argentina/Buenos_Aires', 'America/Chicago', @@ -45,43 +45,88 @@ export const CLOCK_TIME_ZONE_OPTIONS = [ 'UTC', ] as const; -export const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; +const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; -const ITEM_TYPE_EDITABLE_PROPERTIES: Record = { +const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record = { radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue'], dice: ['title', 'sides', 'number'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], }; -export const ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { +const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 }, dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 }, wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 }, clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 }, }; -export const EDITABLE_ITEM_PROPERTY_KEYS = new Set( - Array.from( - new Set( - Object.values(ITEM_TYPE_EDITABLE_PROPERTIES).flatMap((keys) => keys), - ), - ), -); - -const OPTION_ITEM_PROPERTY_VALUES: Partial> = { - effect: EFFECT_SEQUENCE.map((effect) => effect.id), - channel: [...RADIO_CHANNEL_OPTIONS], - timeZone: [...CLOCK_TIME_ZONE_OPTIONS], +type UiDefinitionsPayload = { + itemTypeOrder?: ItemType[]; + itemTypes?: Array<{ + type: ItemType; + label?: string; + editableProperties?: string[]; + propertyOptions?: Record; + globalProperties?: Record; + }>; }; +let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE]; +let itemTypeLabels: Record = { + radio_station: 'radio', + dice: 'dice', + wheel: 'wheel', + clock: 'clock', +}; +let itemTypeEditableProperties: Record = { + radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station], + dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice], + wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel], + clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock], +}; +let itemTypeGlobalProperties: Record> = { + radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station }, + dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice }, + wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel }, + clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock }, +}; +let optionItemPropertyValues: Partial> = { + effect: EFFECT_SEQUENCE.map((effect) => effect.id), + channel: [...RADIO_CHANNEL_OPTIONS], + timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS], +}; + +export let EDITABLE_ITEM_PROPERTY_KEYS = new Set( + Object.values(itemTypeEditableProperties).flatMap((keys) => keys), +); + +function rebuildEditablePropertyKeySet(): void { + EDITABLE_ITEM_PROPERTY_KEYS = new Set(Object.values(itemTypeEditableProperties).flatMap((keys) => keys)); +} + +export function getClockTimeZoneOptions(): string[] { + return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)]; +} + +export function getDefaultClockTimeZone(): string { + return getClockTimeZoneOptions()[0] ?? 'America/Detroit'; +} + +export function getItemTypeSequence(): ItemType[] { + return [...itemTypeSequence]; +} + +export function getItemTypeGlobalProperties(itemType: ItemType): Record { + return itemTypeGlobalProperties[itemType] ?? {}; +} + export function getItemPropertyOptionValues(key: string): string[] | undefined { - return OPTION_ITEM_PROPERTY_VALUES[key]; + return optionItemPropertyValues[key]; } export function itemTypeLabel(type: ItemType): string { - if (type === 'radio_station') return 'radio'; - return type; + return itemTypeLabels[type] ?? type; } export function itemPropertyLabel(key: string): string { @@ -90,7 +135,7 @@ export function itemPropertyLabel(key: string): string { } export function getEditableItemPropertyKeys(item: WorldItem): string[] { - return [...(ITEM_TYPE_EDITABLE_PROPERTIES[item.type] ?? ['title'])]; + return [...(itemTypeEditableProperties[item.type] ?? ['title'])]; } export function getInspectItemPropertyKeys(item: WorldItem): string[] { @@ -124,7 +169,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] { allKeys.push(key); } - const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[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) { if (seen.has(key)) continue; seen.add(key); @@ -133,3 +178,56 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] { return allKeys; } + +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) { + rebuildEditablePropertyKeySet(); + return; + } + + const nextLabels = { ...itemTypeLabels }; + const nextEditable = { ...itemTypeEditableProperties }; + const nextGlobals = { ...itemTypeGlobalProperties }; + const nextOptions: Partial> = { ...optionItemPropertyValues }; + + for (const definition of uiDefinitions.itemTypes) { + if (!definition || typeof definition.type !== 'string') continue; + const itemType = definition.type as ItemType; + if (typeof definition.label === 'string' && definition.label.trim()) { + nextLabels[itemType] = definition.label.trim(); + } + if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) { + nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string'); + } + if (definition.globalProperties && typeof definition.globalProperties === 'object') { + const normalized: Record = {}; + for (const [key, raw] of Object.entries(definition.globalProperties)) { + if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') { + normalized[key] = raw; + } + } + 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; + } + } + } + } + + itemTypeLabels = nextLabels; + itemTypeEditableProperties = nextEditable; + itemTypeGlobalProperties = nextGlobals; + optionItemPropertyValues = nextOptions; + rebuildEditablePropertyKeySet(); +} diff --git a/client/src/main.ts b/client/src/main.ts index 4a9cff9..b81923d 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -32,10 +32,11 @@ import { type WorldItem, } from './state/gameState'; import { - CLOCK_TIME_ZONE_OPTIONS, + applyServerItemUiDefinitions, EDITABLE_ITEM_PROPERTY_KEYS, - ITEM_TYPE_GLOBAL_PROPERTIES, - ITEM_TYPE_SEQUENCE, + getDefaultClockTimeZone, + getItemTypeGlobalProperties, + getItemTypeSequence, getEditableItemPropertyKeys, getInspectItemPropertyKeys, getItemPropertyOptionValues, @@ -159,6 +160,7 @@ const WALL_SOUND_URL = withBase('sounds/wall.ogg'); const state = createInitialState(); const renderer = new CanvasRenderer(dom.canvas); const audio = new AudioEngine(); +let worldGridSize = GRID_SIZE; let localStream: MediaStream | null = null; let outboundStream: MediaStream | null = null; let statusTimeout: number | null = null; @@ -690,12 +692,12 @@ function getItemPropertyValue(item: WorldItem, key: string): string { if (key === 'useSound') return item.useSound ?? 'none'; if (key === 'emitSound') return item.emitSound ?? 'none'; if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; - if (key === 'timeZone') return String(item.params.timeZone ?? CLOCK_TIME_ZONE_OPTIONS[0]); + if (key === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone()); if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off'; if (key === 'channel') return normalizeRadioChannel(item.params.channel); if (key === 'effect') return normalizeRadioEffect(item.params.effect); if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue)); - const globalValue = ITEM_TYPE_GLOBAL_PROPERTIES[item.type]?.[key]; + const globalValue = getItemTypeGlobalProperties(item.type)?.[key]; if (globalValue !== undefined) return String(globalValue); return String(item.params[key] ?? ''); } @@ -753,7 +755,7 @@ function handleMovement(): void { const nextX = state.player.x + dx; const nextY = state.player.y + dy; - if (nextX < 0 || nextY < 0 || nextX >= GRID_SIZE || nextY >= GRID_SIZE) { + if (nextX < 0 || nextY < 0 || nextX >= worldGridSize || nextY >= worldGridSize) { state.player.lastMoveTime = now; void audio.playSample(WALL_SOUND_URL, 1); return; @@ -861,8 +863,8 @@ async function connect(): Promise { return; } - state.player.x = Math.floor(Math.random() * GRID_SIZE); - state.player.y = Math.floor(Math.random() * GRID_SIZE); + state.player.x = Math.floor(Math.random() * worldGridSize); + state.player.y = Math.floor(Math.random() * worldGridSize); const storedPosition = localStorage.getItem('spatialChatPosition'); if (storedPosition) { try { @@ -870,7 +872,7 @@ async function connect(): Promise { if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) { const x = Math.floor(parsed.x as number); const y = Math.floor(parsed.y as number); - if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) { + if (x >= 0 && x < worldGridSize && y >= 0 && y < worldGridSize) { state.player.x = x; state.player.y = y; } @@ -959,9 +961,17 @@ function disconnect(): void { async function onMessage(message: IncomingMessage): Promise { switch (message.type) { case 'welcome': + if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) { + worldGridSize = message.worldConfig.gridSize; + } + renderer.setGridSize(worldGridSize); + applyServerItemUiDefinitions(message.uiDefinitions); + state.addItemTypeIndex = 0; state.player.id = message.id; state.running = true; connecting = false; + state.player.x = Math.max(0, Math.min(worldGridSize - 1, state.player.x)); + state.player.y = Math.max(0, Math.min(worldGridSize - 1, state.player.y)); dom.nicknameContainer.classList.add('hidden'); dom.connectButton.classList.add('hidden'); dom.disconnectButton.classList.remove('hidden'); @@ -1258,8 +1268,15 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void { } if (code === 'KeyA') { + const itemTypeSequence = getItemTypeSequence(); + if (itemTypeSequence.length === 0) { + updateStatus('No item types available.'); + audio.sfxUiCancel(); + return; + } + state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1)); state.mode = 'addItem'; - updateStatus(`Add item: ${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`); + updateStatus(`Add item: ${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`); audio.sfxUiBlip(); return; } @@ -1702,29 +1719,36 @@ function handleListItemsModeInput(code: string, key: string): void { } function handleAddItemModeInput(code: string, key: string): void { + const itemTypeSequence = getItemTypeSequence(); + if (itemTypeSequence.length === 0) { + state.mode = 'normal'; + updateStatus('No item types available.'); + audio.sfxUiCancel(); + return; + } if (code === 'ArrowDown' || code === 'ArrowUp') { state.addItemTypeIndex = code === 'ArrowDown' - ? (state.addItemTypeIndex + 1) % ITEM_TYPE_SEQUENCE.length - : (state.addItemTypeIndex - 1 + ITEM_TYPE_SEQUENCE.length) % ITEM_TYPE_SEQUENCE.length; - updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`); + ? (state.addItemTypeIndex + 1) % itemTypeSequence.length + : (state.addItemTypeIndex - 1 + itemTypeSequence.length) % itemTypeSequence.length; + updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`); audio.sfxUiBlip(); return; } const nextByInitial = findNextIndexByInitial( - ITEM_TYPE_SEQUENCE, + itemTypeSequence, state.addItemTypeIndex, key, (itemType) => itemTypeLabel(itemType), ); if (nextByInitial >= 0) { state.addItemTypeIndex = nextByInitial; - updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`); + updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`); audio.sfxUiBlip(); return; } if (code === 'Enter') { - signaling.send({ type: 'item_add', itemType: ITEM_TYPE_SEQUENCE[state.addItemTypeIndex] }); + signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] }); state.mode = 'normal'; return; } diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index d4dc8b6..9d81f54 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -29,6 +29,25 @@ export const welcomeMessageSchema = z.object({ }), ), items: z.array(itemSchema).optional(), + worldConfig: z + .object({ + gridSize: z.number().int().positive(), + }) + .optional(), + uiDefinitions: z + .object({ + itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock'])), + itemTypes: z.array( + z.object({ + type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), + label: z.string().optional(), + editableProperties: z.array(z.string()), + propertyOptions: z.record(z.string(), z.array(z.string())).optional(), + globalProperties: z.record(z.string(), z.unknown()).optional(), + }), + ), + }) + .optional(), }); export const signalMessageSchema = z.object({ diff --git a/client/src/render/canvasRenderer.ts b/client/src/render/canvasRenderer.ts index cdfa742..b862d37 100644 --- a/client/src/render/canvasRenderer.ts +++ b/client/src/render/canvasRenderer.ts @@ -2,7 +2,8 @@ import { GRID_SIZE, type GameState, type PeerState, type WorldItem } from '../st export class CanvasRenderer { private readonly ctx: CanvasRenderingContext2D; - private readonly squarePixelSize: number; + private squarePixelSize: number; + private gridSize: number; constructor(private readonly canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); @@ -10,14 +11,21 @@ export class CanvasRenderer { throw new Error('Unable to create 2D context'); } this.ctx = ctx; - this.squarePixelSize = canvas.width / GRID_SIZE; + this.gridSize = GRID_SIZE; + this.squarePixelSize = canvas.width / this.gridSize; + } + + setGridSize(gridSize: number): void { + if (!Number.isInteger(gridSize) || gridSize <= 0) return; + this.gridSize = gridSize; + this.squarePixelSize = this.canvas.width / this.gridSize; } draw(state: GameState): void { const { ctx } = this; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.strokeStyle = '#374151'; - for (let i = 0; i <= GRID_SIZE; i += 1) { + for (let i = 0; i <= this.gridSize; i += 1) { ctx.beginPath(); ctx.moveTo(i * this.squarePixelSize, 0); ctx.lineTo(i * this.squarePixelSize, this.canvas.height); diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index ee52c8d..56fc3d6 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -18,7 +18,7 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Server -> Client -- `welcome`: initial snapshot with users/items. +- `welcome`: initial snapshot with users/items plus server UI/world metadata. - `signal`: forwarded WebRTC offer/answer/ICE. - `update_position`, `update_nickname`, `user_left`: presence updates. - `chat_message`: system and user chat stream. @@ -35,6 +35,17 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_action_result` messages are intended for direct screen-reader/user status feedback. - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path. +## Welcome Metadata + +- `welcome.worldConfig.gridSize`: server-authoritative grid size used by clients for bounds/drawing. +- `welcome.uiDefinitions`: server-provided item UI definitions: + - `itemTypeOrder`: add-item menu order + - `itemTypes[].editableProperties`: editable property keys by item type + - `itemTypes[].propertyOptions`: menu options for property keys (for example clock `timeZone`) + - `itemTypes[].globalProperties`: non-editable global values (`useSound`, `emitSound`, `useCooldownMs`) + +- Clients keep local fallback defaults but should prefer server-provided metadata when present. + ## Validation Boundaries - Server is authoritative for all action validation and normalization. diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 333b008..cfcb87e 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -7,6 +7,8 @@ 3. Client connects signaling websocket. 4. Server sends `welcome` with users/items snapshot. 5. Client: + - applies `welcome.worldConfig.gridSize` for authoritative grid bounds/rendering + - applies `welcome.uiDefinitions` for item menus/properties/options - sends initial `update_position` - sends initial `update_nickname` - creates peer runtimes for known users diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index e601399..63dee7b 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -6,6 +6,21 @@ from dataclasses import dataclass from typing import Literal ItemType = Literal["radio_station", "dice", "wheel", "clock"] +ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = ("clock", "dice", "radio_station", "wheel") +ITEM_TYPE_LABELS: dict[ItemType, str] = { + "radio_station": "radio", + "dice": "dice", + "wheel": "wheel", + "clock": "clock", +} +RADIO_EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off") +RADIO_CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right") +ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = { + "radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue"), + "dice": ("title", "sides", "number"), + "wheel": ("title", "spaces"), + "clock": ("title", "timeZone", "use24Hour"), +} CLOCK_DEFAULT_TIME_ZONE = "America/Detroit" CLOCK_TIME_ZONE_OPTIONS: tuple[str, ...] = ( "America/Anchorage", @@ -95,6 +110,12 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { ), } +ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = { + "effect": RADIO_EFFECT_OPTIONS, + "channel": RADIO_CHANNEL_OPTIONS, + "timeZone": CLOCK_TIME_ZONE_OPTIONS, +} + def get_item_definition(item_type: ItemType) -> ItemDefinition: """Return catalog definition for a known item type.""" @@ -110,3 +131,14 @@ def get_item_use_cooldown_ms(item_type: ItemType) -> int: if isinstance(cooldown_ms, int) and cooldown_ms > 0: return cooldown_ms return 1000 + + +def get_item_global_properties(item_type: ItemType) -> dict[str, str | int]: + """Return non-editable global properties exposed in UI metadata.""" + + definition = get_item_definition(item_type) + return { + "useSound": definition.use_sound or "none", + "emitSound": definition.emit_sound or "none", + "useCooldownMs": get_item_use_cooldown_ms(item_type), + } diff --git a/server/app/models.py b/server/app/models.py index 38c7cba..f3a3e70 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -101,6 +101,8 @@ class WelcomePacket(BasePacket): id: str users: list[RemoteUser] items: list[dict] | None = None + worldConfig: dict | None = None + uiDefinitions: dict | None = None class UserLeftPacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 34c7ae5..9bf6755 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -18,7 +18,16 @@ from websockets.asyncio.server import ServerConnection, serve from .client import ClientConnection from .config import load_config -from .item_catalog import CLOCK_DEFAULT_TIME_ZONE, CLOCK_TIME_ZONE_OPTIONS, get_item_use_cooldown_ms +from .item_catalog import ( + CLOCK_DEFAULT_TIME_ZONE, + CLOCK_TIME_ZONE_OPTIONS, + ITEM_PROPERTY_OPTIONS, + ITEM_TYPE_EDITABLE_PROPERTIES, + ITEM_TYPE_LABELS, + ITEM_TYPE_SEQUENCE, + get_item_global_properties, + get_item_use_cooldown_ms, +) from .item_type_handlers import get_item_type_handler from .item_service import ItemService from .models import ( @@ -236,9 +245,36 @@ class SignalingServer: id=client.id, users=users, items=[item.model_dump(exclude_none=True) for item in self.items.values()], + worldConfig={"gridSize": self.grid_size}, + uiDefinitions=self._build_ui_definitions(), ) await self._send(client.websocket, packet) + def _build_ui_definitions(self) -> dict: + """Build server-owned UI definitions for item/menu rendering.""" + + item_types: list[dict] = [] + for item_type in ITEM_TYPE_SEQUENCE: + 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( + { + "type": item_type, + "label": ITEM_TYPE_LABELS.get(item_type, item_type), + "editableProperties": editable, + "propertyOptions": property_options, + "globalProperties": get_item_global_properties(item_type), + } + ) + return { + "itemTypeOrder": list(ITEM_TYPE_SEQUENCE), + "itemTypes": item_types, + } + async def _broadcast_wheel_result_after_delay( self, client: ClientConnection,