Send world/item UI metadata in welcome and consume on client
This commit is contained in:
@@ -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<ItemType, string[]> = {
|
||||
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||
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<ItemType, Record<string, string | number | boolean>> = {
|
||||
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||
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<string>(
|
||||
Array.from(
|
||||
new Set(
|
||||
Object.values(ITEM_TYPE_EDITABLE_PROPERTIES).flatMap((keys) => keys),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const OPTION_ITEM_PROPERTY_VALUES: Partial<Record<string, string[]>> = {
|
||||
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<string, string[]>;
|
||||
globalProperties?: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE];
|
||||
let itemTypeLabels: Record<ItemType, string> = {
|
||||
radio_station: 'radio',
|
||||
dice: 'dice',
|
||||
wheel: 'wheel',
|
||||
clock: 'clock',
|
||||
};
|
||||
let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
||||
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<ItemType, Record<string, string | number | boolean>> = {
|
||||
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<Record<string, string[]>> = {
|
||||
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<string>(
|
||||
Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
|
||||
);
|
||||
|
||||
function rebuildEditablePropertyKeySet(): void {
|
||||
EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(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<string, string | number | boolean> {
|
||||
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<Record<string, string[]>> = { ...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<string, string | number | boolean> = {};
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user