diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts new file mode 100644 index 0000000..4f03d75 --- /dev/null +++ b/client/src/items/itemRegistry.ts @@ -0,0 +1,135 @@ +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 = [ + 'America/Anchorage', + 'America/Argentina/Buenos_Aires', + 'America/Chicago', + 'America/Detroit', + 'America/Halifax', + 'America/Indiana/Indianapolis', + 'America/Kentucky/Louisville', + 'America/Los_Angeles', + 'America/St_Johns', + 'Asia/Bangkok', + 'Asia/Dhaka', + 'Asia/Dubai', + 'Asia/Hong_Kong', + 'Asia/Kabul', + 'Asia/Karachi', + 'Asia/Kathmandu', + 'Asia/Kolkata', + 'Asia/Seoul', + 'Asia/Singapore', + 'Asia/Tehran', + 'Asia/Tokyo', + 'Asia/Yangon', + 'Atlantic/Azores', + 'Atlantic/South_Georgia', + 'Australia/Brisbane', + 'Australia/Darwin', + 'Australia/Eucla', + 'Australia/Lord_Howe', + 'Europe/Berlin', + 'Europe/Helsinki', + 'Europe/London', + 'Europe/Moscow', + 'Pacific/Apia', + 'Pacific/Auckland', + 'Pacific/Chatham', + 'Pacific/Honolulu', + 'Pacific/Kiritimati', + 'Pacific/Noumea', + 'Pacific/Pago_Pago', + 'UTC', +] as const; + +export const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; + +const 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> = { + 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], +}; + +export function getItemPropertyOptionValues(key: string): string[] | undefined { + return OPTION_ITEM_PROPERTY_VALUES[key]; +} + +export function itemTypeLabel(type: ItemType): string { + if (type === 'radio_station') return 'radio'; + return type; +} + +export function itemPropertyLabel(key: string): string { + if (key === 'use24Hour') return 'use 24 hour format'; + return key; +} + +export function getEditableItemPropertyKeys(item: WorldItem): string[] { + return [...(ITEM_TYPE_EDITABLE_PROPERTIES[item.type] ?? ['title'])]; +} + +export function getInspectItemPropertyKeys(item: WorldItem): string[] { + const editableKeys = getEditableItemPropertyKeys(item); + const seen = new Set(editableKeys); + const allKeys: string[] = [...editableKeys]; + + const baseKeys = [ + 'type', + 'x', + 'y', + 'carrierId', + 'version', + 'createdBy', + 'createdAt', + 'updatedAt', + 'capabilities', + 'useSound', + 'emitSound', + ]; + for (const key of baseKeys) { + if (seen.has(key)) continue; + seen.add(key); + allKeys.push(key); + } + + const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b)); + for (const key of paramKeys) { + if (seen.has(key)) continue; + seen.add(key); + allKeys.push(key); + } + + const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[item.type] ?? {}).sort((a, b) => a.localeCompare(b)); + for (const key of globalKeys) { + if (seen.has(key)) continue; + seen.add(key); + allKeys.push(key); + } + + return allKeys; +} diff --git a/client/src/main.ts b/client/src/main.ts index 4e2dc0f..4a9cff9 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -6,7 +6,7 @@ import { clampEffectLevel, type EffectId, } from './audio/effects'; -import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime'; +import { RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime'; import { ItemEmitRuntime } from './audio/itemEmitRuntime'; import { applyPastedText, @@ -29,9 +29,19 @@ import { getDirection, getNearestItem, getNearestPeer, - type ItemType, type WorldItem, } from './state/gameState'; +import { + CLOCK_TIME_ZONE_OPTIONS, + EDITABLE_ITEM_PROPERTY_KEYS, + ITEM_TYPE_GLOBAL_PROPERTIES, + ITEM_TYPE_SEQUENCE, + getEditableItemPropertyKeys, + getInspectItemPropertyKeys, + getItemPropertyOptionValues, + itemPropertyLabel, + itemTypeLabel, +} from './items/itemRegistry'; import { PeerManager } from './webrtc/peerManager'; const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels'; @@ -128,77 +138,9 @@ type AudioLayerState = { const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim(); const DISPLAY_TIME_ZONE = resolveDisplayTimeZone(); -const CLOCK_TIME_ZONE_OPTIONS = [ - 'America/Anchorage', - 'America/Argentina/Buenos_Aires', - 'America/Chicago', - 'America/Detroit', - 'America/Halifax', - 'America/Indiana/Indianapolis', - 'America/Kentucky/Louisville', - 'America/Los_Angeles', - 'America/St_Johns', - 'Asia/Bangkok', - 'Asia/Dhaka', - 'Asia/Dubai', - 'Asia/Hong_Kong', - 'Asia/Kabul', - 'Asia/Karachi', - 'Asia/Kathmandu', - 'Asia/Kolkata', - 'Asia/Seoul', - 'Asia/Singapore', - 'Asia/Tehran', - 'Asia/Tokyo', - 'Asia/Yangon', - 'Atlantic/Azores', - 'Atlantic/South_Georgia', - 'Australia/Brisbane', - 'Australia/Darwin', - 'Australia/Eucla', - 'Australia/Lord_Howe', - 'Europe/Berlin', - 'Europe/Helsinki', - 'Europe/London', - 'Europe/Moscow', - 'Pacific/Apia', - 'Pacific/Auckland', - 'Pacific/Chatham', - 'Pacific/Honolulu', - 'Pacific/Kiritimati', - 'Pacific/Noumea', - 'Pacific/Pago_Pago', - 'UTC', -] as const; dom.appVersion.textContent = APP_VERSION ? `Another AI experiment with Jage. Version ${APP_VERSION}` : 'Another AI experiment with Jage. Version unknown'; -const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; -const 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 }, -}; -const EDITABLE_ITEM_PROPERTY_KEYS = new Set([ - 'title', - 'streamUrl', - 'enabled', - 'channel', - 'volume', - 'effect', - 'effectValue', - 'spaces', - 'sides', - 'number', - 'timeZone', - 'use24Hour', -]); -const OPTION_ITEM_PROPERTY_VALUES: Partial> = { - effect: EFFECT_SEQUENCE.map((effect) => effect.id), - channel: [...RADIO_CHANNEL_OPTIONS], - timeZone: [...CLOCK_TIME_ZONE_OPTIONS], -}; const APP_BASE_URL = import.meta.env.BASE_URL || '/'; function withBase(path: string): string { const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`; @@ -574,20 +516,10 @@ function getPeerNamesAtPosition(x: number, y: number): string[] { .map((peer) => peer.nickname); } -function itemTypeLabel(type: ItemType): string { - if (type === 'radio_station') return 'radio'; - return type; -} - function itemLabel(item: WorldItem): string { return `${item.title} (${itemTypeLabel(item.type)})`; } -function itemPropertyLabel(key: string): string { - if (key === 'use24Hour') return 'use 24 hour format'; - return key; -} - function openHelpViewer(): void { if (helpViewerLines.length === 0) { updateStatus('Help unavailable.'); @@ -623,61 +555,6 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins audio.sfxUiBlip(); } -function getEditableItemPropertyKeys(item: WorldItem): string[] { - const keys = ['title']; - if (item.type === 'radio_station') { - keys.push('streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue'); - } else if (item.type === 'dice') { - keys.push('sides', 'number'); - } else if (item.type === 'wheel') { - keys.push('spaces'); - } else if (item.type === 'clock') { - keys.push('timeZone', 'use24Hour'); - } - return keys; -} - -function getInspectItemPropertyKeys(item: WorldItem): string[] { - const editableKeys = getEditableItemPropertyKeys(item); - const seen = new Set(editableKeys); - const allKeys: string[] = [...editableKeys]; - - const baseKeys = [ - 'type', - 'x', - 'y', - 'carrierId', - 'version', - 'createdBy', - 'createdAt', - 'updatedAt', - 'capabilities', - 'useSound', - 'emitSound', - ]; - for (const key of baseKeys) { - if (seen.has(key)) continue; - seen.add(key); - allKeys.push(key); - } - - const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b)); - for (const key of paramKeys) { - if (seen.has(key)) continue; - seen.add(key); - allKeys.push(key); - } - - const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[item.type] ?? {}).sort((a, b) => a.localeCompare(b)); - for (const key of globalKeys) { - if (seen.has(key)) continue; - seen.add(key); - allKeys.push(key); - } - - return allKeys; -} - function beginItemProperties(item: WorldItem, showAll = false): void { state.selectedItemId = item.id; state.mode = 'itemProperties'; @@ -701,7 +578,7 @@ function useItem(item: WorldItem): void { } function openItemPropertyOptionSelect(item: WorldItem, key: string): void { - const options = OPTION_ITEM_PROPERTY_VALUES[key]; + const options = getItemPropertyOptionValues(key); if (!options || options.length === 0) { return; } @@ -1999,7 +1876,7 @@ function handleItemPropertiesModeInput(code: string, key: string): void { audio.sfxUiBlip(); return; } - if (OPTION_ITEM_PROPERTY_VALUES[key]) { + if (getItemPropertyOptionValues(key)) { openItemPropertyOptionSelect(item, key); return; } diff --git a/docs/item-schema.md b/docs/item-schema.md index 5d0cb73..61e6c13 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -46,6 +46,8 @@ - Persisted state stores only instance data. - Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`. +- Per-type use/update validation and message behavior are handled in `server/app/item_type_handlers.py`. +- Client-side add/edit metadata is handled in `client/src/items/itemRegistry.ts`. ## Type Params diff --git a/docs/item-types.md b/docs/item-types.md index c478032..cfa5956 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -95,3 +95,28 @@ This is behavior-focused documentation for item types and their defaults. ### Validation - `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py` - `use24Hour`: boolean or on/off style input + +## Adding A New Item Type (Registry V1) + +Item types are currently code-registered on both server and client so new types are additive instead of editing one large branch. + +1. Server catalog: add global defaults in `server/app/item_catalog.py` (`ITEM_DEFINITIONS`). +2. Server handlers: add `validate_update` + `use` logic in `server/app/item_type_handlers.py` and register it in `ITEM_TYPE_HANDLERS`. +3. Server models: extend `ItemType` literals in `server/app/models.py` and any packet enums that list item types. +4. Client registry: add type metadata in `client/src/items/itemRegistry.ts` (`ITEM_TYPE_SEQUENCE`, editable properties, and global property hints). +5. Client protocol types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`. +6. Tests: add or update server tests under `server/tests/` for use/update validation and cooldown behavior. + +### Example Shape + +A minimal new item type usually needs: + +- Catalog defaults: + - `default_title` + - `default_params` + - `use_sound` / `emit_sound` + - `use_cooldown_ms` +- Handler behavior: + - validate params on update + - build self/others use messages + - optionally return delayed result text diff --git a/server/app/item_type_handlers.py b/server/app/item_type_handlers.py new file mode 100644 index 0000000..83c6bb5 --- /dev/null +++ b/server/app/item_type_handlers.py @@ -0,0 +1,237 @@ +"""Per-item-type use/update handlers for modular item behavior.""" + +from __future__ import annotations + +from dataclasses import dataclass +import random +from typing import Callable + +from .item_catalog import CLOCK_DEFAULT_TIME_ZONE, CLOCK_TIME_ZONE_OPTIONS, ItemType +from .models import WorldItem + +RADIO_EFFECT_IDS = {"reverb", "echo", "flanger", "high_pass", "low_pass", "off"} +RADIO_CHANNEL_IDS = {"stereo", "mono", "left", "right"} + + +@dataclass(frozen=True) +class ItemUseResult: + """Result payload for a successful item use action.""" + + self_message: str + others_message: str + updated_params: dict | None = None + delayed_self_message: str | None = None + delayed_others_message: str | None = None + + +def _parse_enabled(value: object) -> bool: + """Parse radio enabled-like values with permissive defaults.""" + + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in {"on", "true", "1", "yes"} + return True + + +def _parse_clock_use_24_hour(value: object) -> bool | None: + """Parse bool-like clock format values (`on/off`, `true/false`, etc.).""" + + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + token = value.strip().lower() + if token in {"on", "true", "1", "yes"}: + return True + if token in {"off", "false", "0", "no"}: + return False + return None + + +def _validate_radio_update(item: WorldItem, next_params: dict) -> dict: + """Validate and normalize `radio_station` params.""" + + stream_url = str(next_params.get("streamUrl", "")).strip() + previous_stream_url = str(item.params.get("streamUrl", "")).strip() + next_params["streamUrl"] = stream_url + enabled_value = next_params.get("enabled", True) + if isinstance(enabled_value, bool): + enabled = enabled_value + elif isinstance(enabled_value, (int, float)): + enabled = bool(enabled_value) + elif isinstance(enabled_value, str): + token = enabled_value.strip().lower() + if token in {"on", "true", "1", "yes"}: + enabled = True + elif token in {"off", "false", "0", "no"}: + enabled = False + else: + raise ValueError("enabled must be true/false or on/off.") + else: + raise ValueError("enabled must be true/false or on/off.") + if stream_url and stream_url != previous_stream_url: + enabled = True + if not stream_url: + enabled = False + next_params["enabled"] = enabled + + try: + volume = int(next_params.get("volume", 50)) + except (TypeError, ValueError) as exc: + raise ValueError("volume must be a number.") from exc + if not (0 <= volume <= 100): + raise ValueError("volume must be between 0 and 100.") + next_params["volume"] = volume + + effect = str(next_params.get("effect", "off")).strip().lower() + if effect not in RADIO_EFFECT_IDS: + raise ValueError("effect must be one of reverb, echo, flanger, high_pass, low_pass, off.") + next_params["effect"] = effect + + channel = str(next_params.get("channel", "stereo")).strip().lower() + if channel not in RADIO_CHANNEL_IDS: + raise ValueError("channel must be one of stereo, mono, left, right.") + next_params["channel"] = channel + + try: + effect_value = float(next_params.get("effectValue", 50)) + except (TypeError, ValueError) as exc: + raise ValueError("effectValue must be a number.") from exc + if not (0 <= effect_value <= 100): + raise ValueError("effectValue must be between 0 and 100.") + next_params["effectValue"] = round(effect_value, 1) + return next_params + + +def _validate_dice_update(_item: WorldItem, next_params: dict) -> dict: + """Validate and normalize `dice` params.""" + + try: + sides = int(next_params.get("sides", 6)) + number = int(next_params.get("number", 2)) + except (TypeError, ValueError) as exc: + raise ValueError("Dice values must be numbers.") from exc + if not (1 <= sides <= 100 and 1 <= number <= 100): + raise ValueError("Dice sides and number must be between 1 and 100.") + next_params["sides"] = sides + next_params["number"] = number + return next_params + + +def _validate_wheel_update(_item: WorldItem, next_params: dict) -> dict: + """Validate and normalize `wheel` params.""" + + spaces_raw = next_params.get("spaces", "") + if not isinstance(spaces_raw, str): + raise ValueError("spaces must be a comma-delimited string.") + spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] + if not spaces: + raise ValueError("spaces must include at least one value, separated by commas.") + if len(spaces) > 100: + raise ValueError("spaces supports up to 100 values.") + if any(len(token) > 80 for token in spaces): + raise ValueError("each space must be 80 chars or less.") + next_params["spaces"] = ", ".join(spaces) + return next_params + + +def _validate_clock_update(_item: WorldItem, next_params: dict) -> dict: + """Validate and normalize `clock` params.""" + + time_zone = str(next_params.get("timeZone", CLOCK_DEFAULT_TIME_ZONE)).strip() + if time_zone not in CLOCK_TIME_ZONE_OPTIONS: + raise ValueError(f"timeZone must be one of {', '.join(CLOCK_TIME_ZONE_OPTIONS)}.") + use_24_hour = _parse_clock_use_24_hour(next_params.get("use24Hour")) + if use_24_hour is None: + raise ValueError("use24Hour must be on/off.") + next_params["timeZone"] = time_zone + next_params["use24Hour"] = use_24_hour + return next_params + + +def _use_radio(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Compute `radio_station` use result and next params.""" + + currently_enabled = _parse_enabled(item.params.get("enabled", True)) + next_enabled = not currently_enabled + state_text = "on" if next_enabled else "off" + return ItemUseResult( + self_message=f"You turn {state_text} {item.title}.", + others_message=f"{nickname} turns {state_text} {item.title}.", + updated_params={**item.params, "enabled": next_enabled}, + ) + + +def _use_dice(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Compute `dice` use result.""" + + try: + sides = max(1, min(100, int(item.params.get("sides", 6)))) + number = max(1, min(100, int(item.params.get("number", 2)))) + except (TypeError, ValueError): + sides = 6 + number = 2 + rolls = [random.randint(1, sides) for _ in range(number)] + total = sum(rolls) + rolls_text = ", ".join(str(value) for value in rolls) + return ItemUseResult( + self_message=f"You rolled {item.title}: {rolls_text} (total {total}).", + others_message=f"{nickname} rolled {item.title}: {rolls_text} (total {total}).", + ) + + +def _use_wheel(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Compute `wheel` use result and delayed result text.""" + + spaces_raw = item.params.get("spaces", "") + if isinstance(spaces_raw, str): + spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] + elif isinstance(spaces_raw, list): + spaces = [str(token).strip() for token in spaces_raw if str(token).strip()] + else: + spaces = [] + if not spaces: + raise ValueError("wheel spaces must contain at least one comma-delimited value.") + landed = str(random.choice(spaces)) + return ItemUseResult( + self_message=f"You spin {item.title}.", + others_message=f"{nickname} spins {item.title}.", + delayed_self_message=landed, + delayed_others_message=landed, + ) + + +def _use_clock(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Compute `clock` use result.""" + + display_time = clock_formatter(item.params) + return ItemUseResult( + self_message=f"{item.title} says {display_time}.", + others_message=f"{nickname} checks {item.title}. {item.title} says {display_time}.", + ) + + +@dataclass(frozen=True) +class ItemTypeHandler: + """Validation and use handlers for one item type.""" + + validate_update: Callable[[WorldItem, dict], dict] + use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] + + +ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = { + "radio_station": ItemTypeHandler(validate_update=_validate_radio_update, use=_use_radio), + "dice": ItemTypeHandler(validate_update=_validate_dice_update, use=_use_dice), + "wheel": ItemTypeHandler(validate_update=_validate_wheel_update, use=_use_wheel), + "clock": ItemTypeHandler(validate_update=_validate_clock_update, use=_use_clock), +} + + +def get_item_type_handler(item_type: ItemType) -> ItemTypeHandler: + """Resolve item-type handler from registry.""" + + return ITEM_TYPE_HANDLERS[item_type] diff --git a/server/app/server.py b/server/app/server.py index 7f40ddb..34c7ae5 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -7,7 +7,6 @@ import asyncio from datetime import datetime import json import logging -import random import ssl import uuid from pathlib import Path @@ -20,6 +19,7 @@ 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_type_handlers import get_item_type_handler from .item_service import ItemService from .models import ( BroadcastChatMessagePacket, @@ -52,8 +52,6 @@ from .models import ( LOGGER = logging.getLogger("chgrid.server") PACKET_LOGGER = logging.getLogger("chgrid.server.packet") CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket) -RADIO_EFFECT_IDS = {"reverb", "echo", "flanger", "high_pass", "low_pass", "off"} -RADIO_CHANNEL_IDS = {"stereo", "mono", "left", "right"} class SignalingServer: @@ -520,9 +518,7 @@ class SignalingServer: if item.carrierId is None and (item.x != client.x or item.y != client.y): await self._send_item_result(client, False, "use", "Item is not on your square.", item.id) return - if item.type not in {"radio_station", "dice", "wheel", "clock"}: - await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id) - return + handler = get_item_type_handler(item.type) now_ms = self.item_service.now_ms() cooldown_ms = get_item_use_cooldown_ms(item.type) last_use_ms = self.item_last_use_ms.get(item.id) @@ -537,68 +533,21 @@ class SignalingServer: item.id, ) return - delayed_wheel_self_result: str | None = None - delayed_wheel_others_result: str | None = None - if item.type == "radio_station": - enabled_value = item.params.get("enabled", True) - if isinstance(enabled_value, bool): - currently_enabled = enabled_value - elif isinstance(enabled_value, (int, float)): - currently_enabled = bool(enabled_value) - elif isinstance(enabled_value, str): - currently_enabled = enabled_value.strip().lower() in {"on", "true", "1", "yes"} - else: - currently_enabled = True - next_enabled = not currently_enabled - item.params = {**item.params, "enabled": next_enabled} + try: + use_result = handler.use(item, client.nickname, self._format_clock_display_time) + except ValueError as exc: + await self._send_item_result(client, False, "use", str(exc), item.id) + return + + if use_result.updated_params is not None: + item.params = use_result.updated_params item.updatedAt = now_ms self.item_service.save_state() await self._broadcast_item(item) - state_text = "on" if next_enabled else "off" - others_message = f"{client.nickname} turns {state_text} {item.title}." - self_message = f"You turn {state_text} {item.title}." - elif item.type == "dice": - try: - sides = max(1, min(100, int(item.params.get("sides", 6)))) - number = max(1, min(100, int(item.params.get("number", 2)))) - except (TypeError, ValueError): - sides = 6 - number = 2 - rolls = [random.randint(1, sides) for _ in range(number)] - total = sum(rolls) - others_message = ( - f"{client.nickname} rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." - ) - self_message = f"You rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." - elif item.type == "wheel": - spaces_raw = item.params.get("spaces", "") - if isinstance(spaces_raw, str): - spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] - elif isinstance(spaces_raw, list): - spaces = [str(token).strip() for token in spaces_raw if str(token).strip()] - else: - spaces = [] - if not spaces: - await self._send_item_result( - client, - False, - "use", - "wheel spaces must contain at least one comma-delimited value.", - item.id, - ) - return - landed = random.choice(spaces) - others_message = f"{client.nickname} spins {item.title}." - self_message = f"You spin {item.title}." - delayed_wheel_self_result = str(landed) - delayed_wheel_others_result = str(landed) - else: - display_time = self._format_clock_display_time(item.params) - others_message = f"{client.nickname} checks {item.title}. {item.title} says {display_time}." - self_message = f"{item.title} says {display_time}." + self.item_last_use_ms[item.id] = now_ms await self._broadcast( - BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True), + BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True), exclude=client.websocket, ) if item.useSound: @@ -611,13 +560,13 @@ class SignalingServer: y=item.y, ) ) - await self._send_item_result(client, True, "use", self_message, item.id) - if delayed_wheel_self_result is not None and delayed_wheel_others_result is not None: + await self._send_item_result(client, True, "use", use_result.self_message, item.id) + if use_result.delayed_self_message is not None and use_result.delayed_others_message is not None: asyncio.create_task( self._broadcast_wheel_result_after_delay( client=client, - self_message=delayed_wheel_self_result, - others_message=delayed_wheel_others_result, + self_message=use_result.delayed_self_message, + others_message=use_result.delayed_others_message, ) ) return @@ -641,145 +590,12 @@ class SignalingServer: item.title = title[:80] if packet.params: next_params = {**item.params, **packet.params} - if item.type == "dice": - try: - sides = int(next_params.get("sides", 6)) - number = int(next_params.get("number", 2)) - except (TypeError, ValueError): - await self._send_item_result(client, False, "update", "Dice values must be numbers.", item.id) - return - if not (1 <= sides <= 100 and 1 <= number <= 100): - await self._send_item_result( - client, False, "update", "Dice sides and number must be between 1 and 100.", item.id - ) - return - next_params["sides"] = sides - next_params["number"] = number - if item.type == "wheel": - spaces_raw = next_params.get("spaces", "") - if not isinstance(spaces_raw, str): - await self._send_item_result( - client, False, "update", "spaces must be a comma-delimited string.", item.id - ) - return - spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] - if not spaces: - await self._send_item_result( - client, - False, - "update", - "spaces must include at least one value, separated by commas.", - item.id, - ) - return - if len(spaces) > 100: - await self._send_item_result(client, False, "update", "spaces supports up to 100 values.", item.id) - return - if any(len(token) > 80 for token in spaces): - await self._send_item_result(client, False, "update", "each space must be 80 chars or less.", item.id) - return - next_params["spaces"] = ", ".join(spaces) - if item.type == "radio_station": - stream_url = str(next_params.get("streamUrl", "")).strip() - previous_stream_url = str(item.params.get("streamUrl", "")).strip() - next_params["streamUrl"] = stream_url - enabled_value = next_params.get("enabled", True) - if isinstance(enabled_value, bool): - enabled = enabled_value - elif isinstance(enabled_value, (int, float)): - enabled = bool(enabled_value) - elif isinstance(enabled_value, str): - token = enabled_value.strip().lower() - if token in {"on", "true", "1", "yes"}: - enabled = True - elif token in {"off", "false", "0", "no"}: - enabled = False - else: - await self._send_item_result( - client, False, "update", "enabled must be true/false or on/off.", item.id - ) - return - else: - await self._send_item_result( - client, False, "update", "enabled must be true/false or on/off.", item.id - ) - return - if stream_url and stream_url != previous_stream_url: - enabled = True - if not stream_url: - enabled = False - next_params["enabled"] = enabled - - try: - volume = int(next_params.get("volume", 50)) - except (TypeError, ValueError): - await self._send_item_result(client, False, "update", "volume must be a number.", item.id) - return - if not (0 <= volume <= 100): - await self._send_item_result( - client, False, "update", "volume must be between 0 and 100.", item.id - ) - return - next_params["volume"] = volume - - effect = str(next_params.get("effect", "off")).strip().lower() - if effect not in RADIO_EFFECT_IDS: - await self._send_item_result( - client, - False, - "update", - "effect must be one of reverb, echo, flanger, high_pass, low_pass, off.", - item.id, - ) - return - next_params["effect"] = effect - - channel = str(next_params.get("channel", "stereo")).strip().lower() - if channel not in RADIO_CHANNEL_IDS: - await self._send_item_result( - client, - False, - "update", - "channel must be one of stereo, mono, left, right.", - item.id, - ) - return - next_params["channel"] = channel - - try: - effect_value = float(next_params.get("effectValue", 50)) - except (TypeError, ValueError): - await self._send_item_result(client, False, "update", "effectValue must be a number.", item.id) - return - if not (0 <= effect_value <= 100): - await self._send_item_result( - client, False, "update", "effectValue must be between 0 and 100.", item.id - ) - return - next_params["effectValue"] = round(effect_value, 1) - if item.type == "clock": - time_zone = str(next_params.get("timeZone", CLOCK_DEFAULT_TIME_ZONE)).strip() - if time_zone not in CLOCK_TIME_ZONE_OPTIONS: - await self._send_item_result( - client, - False, - "update", - f"timeZone must be one of {', '.join(CLOCK_TIME_ZONE_OPTIONS)}.", - item.id, - ) - return - use_24_hour = self._parse_clock_use_24_hour(next_params.get("use24Hour")) - if use_24_hour is None: - await self._send_item_result( - client, - False, - "update", - "use24Hour must be on/off.", - item.id, - ) - return - next_params["timeZone"] = time_zone - next_params["use24Hour"] = use_24_hour + handler = get_item_type_handler(item.type) + try: + next_params = handler.validate_update(item, next_params) + except ValueError as exc: + await self._send_item_result(client, False, "update", str(exc), item.id) + return item.params = next_params item.updatedAt = self.item_service.now_ms() item.version += 1