From bb36a007e255a3b663723da032581eda82a1a1c5 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 22:20:15 -0500 Subject: [PATCH] Add widget item type with editable sound and spatial controls --- client/public/version.js | 2 +- client/src/audio/itemEmitRuntime.ts | 4 +- client/src/items/itemRegistry.ts | 9 ++- client/src/main.ts | 38 ++++++++- client/src/network/protocol.ts | 8 +- client/src/render/canvasRenderer.ts | 14 +++- client/src/state/gameState.ts | 2 +- docs/item-schema.md | 30 +++++-- docs/item-types.md | 29 +++++++ server/app/item_catalog.py | 2 +- server/app/item_service.py | 2 +- server/app/items/registry.py | 5 +- server/app/items/widget.py | 107 +++++++++++++++++++++++++ server/app/models.py | 6 +- server/app/server.py | 19 ++++- server/tests/test_item_use_cooldown.py | 59 ++++++++++++++ 16 files changed, 309 insertions(+), 27 deletions(-) create mode 100644 server/app/items/widget.py diff --git a/client/public/version.js b/client/public/version.js index 2414bfb..1a68559 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.21 R125"; +window.CHGRID_WEB_VERSION = "2026.02.21 R126"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index d590ba0..1dbce2e 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -65,7 +65,9 @@ export class ItemEmitRuntime { if (!audioCtx) return; for (const item of items) { - const soundUrl = this.resolveSoundUrl(String(item.emitSound ?? '').trim()); + const emitSound = String(item.params.emitSound ?? item.emitSound ?? '').trim(); + const enabled = item.params.enabled !== false; + const soundUrl = enabled ? this.resolveSoundUrl(emitSound) : ''; if (!soundUrl || item.carrierId) { this.cleanup(item.id); continue; diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index c917418..4b4e653 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -45,13 +45,14 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [ 'UTC', ] as const; -const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; +const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel', 'widget']; const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record = { radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'], dice: ['title', 'sides', 'number'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], + widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'useSound', 'emitSound'], }; const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { @@ -59,6 +60,7 @@ const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record = { dice: 'dice', wheel: 'wheel', clock: 'clock', + widget: 'widget', }; let itemTypeTooltips: Partial> = {}; let itemTypeEditableProperties: Record = { @@ -99,12 +102,14 @@ let itemTypeEditableProperties: Record = { dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice], wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel], clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock], + widget: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.widget], }; 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 }, + widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget }, }; let optionItemPropertyValues: Partial> = { effect: EFFECT_SEQUENCE.map((effect) => effect.id), @@ -188,6 +193,8 @@ export function itemTypeLabel(type: ItemType): string { export function itemPropertyLabel(key: string): string { if (key === 'use24Hour') return 'use 24 hour format'; if (key === 'emitRange') return 'emit range'; + if (key === 'useSound') return 'use sound'; + if (key === 'emitSound') return 'emit sound'; return key; } diff --git a/client/src/main.ts b/client/src/main.ts index e103731..23b87d5 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -531,7 +531,7 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo const rawGlobalRange = Number(global.emitRange); const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange; const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15; - const directional = global.directional === true; + const directional = typeof item.params.directional === 'boolean' ? item.params.directional : global.directional === true; const rawFacing = Number(item.params.facing ?? 0); const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0; return { range, directional, facingDeg }; @@ -694,6 +694,14 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe } function getItemPropertyValue(item: WorldItem, key: string): string { + const toSoundDisplayName = (rawValue: unknown): string => { + const raw = String(rawValue ?? '').trim(); + if (!raw) return 'none'; + if (raw.toLowerCase() === 'none') return 'none'; + const withoutQuery = raw.split('?')[0].split('#')[0]; + const segments = withoutQuery.split('/').filter((part) => part.length > 0); + return segments[segments.length - 1] ?? raw; + }; if (key === 'title') return item.title; if (key === 'type') return item.type; if (key === 'x') return String(item.x); @@ -704,9 +712,15 @@ function getItemPropertyValue(item: WorldItem, key: string): string { if (key === 'createdAt') return formatTimestampMs(item.createdAt); if (key === 'updatedAt') return formatTimestampMs(item.updatedAt); if (key === 'capabilities') return item.capabilities.join(', ') || 'none'; - if (key === 'useSound') return item.useSound ?? 'none'; - if (key === 'emitSound') return item.emitSound ?? 'none'; + if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound); + if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound); if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; + if (key === 'directional') { + if (typeof item.params.directional === 'boolean') { + return item.params.directional ? 'on' : 'off'; + } + return getItemTypeGlobalProperties(item.type).directional === true ? 'on' : 'off'; + } 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); @@ -2033,6 +2047,13 @@ function handleItemPropertiesModeInput(code: string, key: string): void { audio.sfxUiBlip(); return; } + if (key === 'directional') { + const nextDirectional = item.params.directional !== true; + signaling.send({ type: 'item_update', itemId, params: { directional: nextDirectional } }); + updateStatus(`directional: ${nextDirectional ? 'on' : 'off'}`); + audio.sfxUiBlip(); + return; + } if (key === 'use24Hour') { const nextUse24Hour = item.params.use24Hour !== true; signaling.send({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } }); @@ -2108,6 +2129,15 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo } const enabled = ['on', 'true', '1', 'yes'].includes(normalized); signaling.send({ type: 'item_update', itemId, params: { enabled } }); + } else if (propertyKey === 'directional') { + const normalized = value.toLowerCase(); + if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) { + updateStatus('directional must be on or off.'); + audio.sfxUiCancel(); + return; + } + const directional = ['on', 'true', '1', 'yes'].includes(normalized); + signaling.send({ type: 'item_update', itemId, params: { directional } }); } else if (propertyKey === 'volume') { const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); if (!parsed.ok) { @@ -2148,6 +2178,8 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo return; } signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed.value } }); + } else if (propertyKey === 'useSound' || propertyKey === 'emitSound') { + signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: value } }); } else if (propertyKey === 'spaces') { const spaces = value .split(',') diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 93eaa5c..4706866 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const itemSchema = z.object({ id: z.string(), - type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), + type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']), title: z.string(), x: z.number().int(), y: z.number().int(), @@ -36,10 +36,10 @@ export const welcomeMessageSchema = z.object({ .optional(), uiDefinitions: z .object({ - itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock'])), + itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget'])), itemTypes: z.array( z.object({ - type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), + type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']), label: z.string().optional(), tooltip: z.string().optional(), editableProperties: z.array(z.string()), @@ -166,7 +166,7 @@ export type OutgoingMessage = | { type: 'update_nickname'; nickname: string } | { type: 'chat_message'; message: string } | { type: 'ping'; clientSentAt: number } - | { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' } + | { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' } | { type: 'item_pickup'; itemId: string } | { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_delete'; itemId: string } diff --git a/client/src/render/canvasRenderer.ts b/client/src/render/canvasRenderer.ts index b862d37..3667169 100644 --- a/client/src/render/canvasRenderer.ts +++ b/client/src/render/canvasRenderer.ts @@ -91,13 +91,23 @@ export class CanvasRenderer { ? '#f97316' : item.type === 'clock' ? '#86efac' - : '#60a5fa'; + : item.type === 'widget' + ? '#22d3ee' + : '#60a5fa'; this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize); this.ctx.fillStyle = '#111827'; this.ctx.font = 'bold 12px Courier New'; this.ctx.textAlign = 'center'; this.ctx.fillText( - item.type === 'radio_station' ? 'R' : item.type === 'wheel' ? 'W' : item.type === 'clock' ? 'C' : 'D', + item.type === 'radio_station' + ? 'R' + : item.type === 'wheel' + ? 'W' + : item.type === 'clock' + ? 'C' + : item.type === 'widget' + ? 'B' + : 'D', drawX + this.squarePixelSize / 2, drawY + 13, ); diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index bd770a5..0dcd2a4 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -2,7 +2,7 @@ export const GRID_SIZE = 41; export const HEARING_RADIUS = 15; export const MOVE_COOLDOWN_MS = 200; -export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock'; +export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget'; export type WorldItem = { id: string; diff --git a/docs/item-schema.md b/docs/item-schema.md index e3730e3..36ef725 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -5,7 +5,7 @@ ```json { "id": "string", - "type": "radio_station | dice | wheel | clock", + "type": "radio_station | dice | wheel | clock | widget", "title": "string", "x": 0, "y": 0, @@ -24,17 +24,17 @@ - `useSound`: optional client-played one-shot sound when item `use` succeeds; global item field and not user-editable in V1. - `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1. - `capabilities`, `useSound`, and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). -- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`), not per-instance editable. -- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`). +- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`, `widget=1000`), not per-instance editable. +- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`, `widget=15`). - `radio_station` can override this per instance via `params.emitRange` (`5..20`). -- `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`), not per-instance editable. +- `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`); `widget` can override per instance via `params.directional`. ## Persisted Item State (`server/runtime/items.json`) ```json { "id": "string", - "type": "radio_station | dice | wheel | clock", + "type": "radio_station | dice | wheel | clock | widget", "title": "string", "x": 0, "y": 0, @@ -128,6 +128,26 @@ - `use24Hour`: boolean (or `on/off` in updates), default `false`. - Global defaults: `useSound=none`, `emitSound=sounds/clock.ogg`. +### `widget` + +```json +{ + "enabled": true, + "directional": false, + "facing": 0, + "emitRange": 15, + "useSound": "", + "emitSound": "" +} +``` + +- `enabled`: boolean (or `on/off` in updates), default `true`. +- `directional`: boolean (or `on/off` in updates), default `false`. +- `facing`: number, range `0-360`, precision `0.1`. +- `emitRange`: integer, range `1-20`, default `15`. +- `useSound`: empty, filename (assumed under `sounds/`), or full URL. +- `emitSound`: empty, filename (assumed under `sounds/`), or full URL. + ## Packet Shapes - `item_upsert`: diff --git a/docs/item-types.md b/docs/item-types.md index 07b7c51..1af3820 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -110,6 +110,35 @@ This is behavior-focused documentation for item types and their defaults. - `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py` - `use24Hour`: boolean or on/off style input +## `widget` + +### Defaults +- Title: `widget` +- Params: + - `enabled=true` + - `directional=false` + - `facing=0` + - `emitRange=15` + - `useSound=""` + - `emitSound=""` +- Global: + - `useSound=none` + - `emitSound=none` + - `useCooldownMs=1000` + - `emitRange=15` + - `directional=false` + +### Use +- `use` toggles `enabled` on/off and plays `useSound` when configured. + +### Validation +- `enabled`: boolean or on/off style input +- `directional`: boolean or on/off style input +- `facing`: number `0..360` with `0.1` precision +- `emitRange`: integer `1..20` +- `useSound`: empty, filename (assumed under `sounds/`), or full URL +- `emitSound`: empty, filename (assumed under `sounds/`), or full URL + ## Adding A New Item Type (Registry V1) Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry. diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index c100417..1f6f88e 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -8,7 +8,7 @@ from typing import Literal, cast from .items import clock, radio from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER -ItemType = Literal["radio_station", "dice", "wheel", "clock"] +ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget"] 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_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = { diff --git a/server/app/item_service.py b/server/app/item_service.py index 1d00b3e..8966d60 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -33,7 +33,7 @@ class ItemService: return int(time.time() * 1000) - def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock"]) -> WorldItem: + def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget"]) -> WorldItem: """Create a new server-authoritative item at the caller's position.""" item_def = get_item_definition(item_type) diff --git a/server/app/items/registry.py b/server/app/items/registry.py index 451d010..d819367 100644 --- a/server/app/items/registry.py +++ b/server/app/items/registry.py @@ -7,7 +7,7 @@ from typing import Callable, Protocol from ..item_types import ItemUseResult from ..models import WorldItem -from . import clock, dice, radio, wheel +from . import clock, dice, radio, wheel, widget class ItemModule(Protocol): @@ -29,11 +29,12 @@ class ItemModule(Protocol): use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] -ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel") +ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel", "widget") ITEM_MODULES: dict[str, ItemModule] = { "clock": clock, "dice": dice, "radio_station": radio, "wheel": wheel, + "widget": widget, } diff --git a/server/app/items/widget.py b/server/app/items/widget.py new file mode 100644 index 0000000..3013c0d --- /dev/null +++ b/server/app/items/widget.py @@ -0,0 +1,107 @@ +"""Widget item schema metadata and behavior.""" + +from __future__ import annotations + +from typing import Callable + +from ..item_types import ItemUseResult +from ..models import WorldItem +from .helpers import parse_bool_like, toggle_bool_param + +LABEL = "widget" +TOOLTIP = "A basic item. Make it a beacon or whatever you want." +EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "enabled", "directional", "facing", "emitRange", "useSound", "emitSound") +CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable") +USE_SOUND: str | None = None +EMIT_SOUND: str | None = None +USE_COOLDOWN_MS = 1000 +EMIT_RANGE = 15 +DIRECTIONAL = False +DEFAULT_TITLE = "widget" +DEFAULT_PARAMS: dict = { + "enabled": True, + "directional": False, + "facing": 0, + "emitRange": 15, + "useSound": "", + "emitSound": "", +} + +PROPERTY_METADATA: dict[str, dict[str, object]] = { + "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."}, + "enabled": {"valueType": "boolean", "tooltip": "Turns this widget on or off."}, + "directional": {"valueType": "boolean", "tooltip": "If on, emitted sound favors the facing direction."}, + "facing": { + "valueType": "number", + "tooltip": "Facing direction in degrees used when directional is on.", + "range": {"min": 0, "max": 360, "step": 0.1}, + }, + "emitRange": { + "valueType": "number", + "tooltip": "Maximum distance in squares for emitted sound.", + "range": {"min": 1, "max": 20, "step": 1}, + }, + "useSound": {"valueType": "sound", "tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL."}, + "emitSound": {"valueType": "sound", "tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL."}, +} + + +def _normalize_sound_value(raw: object) -> str: + """Normalize sound value to empty/URL/or sounds-relative path.""" + + token = str(raw or "").strip() + if not token: + return "" + lowered = token.lower() + if lowered in {"none", "off"}: + return "" + if lowered.startswith(("http://", "https://", "data:", "blob:")): + return token + if token.startswith("/sounds/"): + return token[1:] + if token.startswith("sounds/"): + return token + if "/" not in token: + return f"sounds/{token}" + return token + + +def validate_update(item: WorldItem, next_params: dict) -> dict: + """Validate and normalize widget params.""" + + enabled = parse_bool_like(next_params.get("enabled", item.params.get("enabled", True)), default=True) + directional = parse_bool_like(next_params.get("directional", item.params.get("directional", False)), default=False) + next_params["enabled"] = enabled + next_params["directional"] = directional + + try: + facing = float(next_params.get("facing", item.params.get("facing", 0))) + except (TypeError, ValueError) as exc: + raise ValueError("facing must be a number between 0 and 360.") from exc + if not (0 <= facing <= 360): + raise ValueError("facing must be between 0 and 360.") + next_params["facing"] = round(facing, 1) + + try: + emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 15))) + except (TypeError, ValueError) as exc: + raise ValueError("emitRange must be an integer between 1 and 20.") from exc + if not (1 <= emit_range <= 20): + raise ValueError("emitRange must be between 1 and 20.") + next_params["emitRange"] = emit_range + + next_params["useSound"] = _normalize_sound_value(next_params.get("useSound", item.params.get("useSound", ""))) + next_params["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", ""))) + return next_params + + +def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: + """Toggle enabled state for widget.""" + + next_enabled = toggle_bool_param(item.params, "enabled", default=True) + 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}, + ) diff --git a/server/app/models.py b/server/app/models.py index f3a3e70..dab2945 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -42,7 +42,7 @@ class PingPacket(BasePacket): class ItemAddPacket(BasePacket): type: Literal["item_add"] - itemType: Literal["radio_station", "dice", "wheel", "clock"] + itemType: Literal["radio_station", "dice", "wheel", "clock", "widget"] class ItemPickupPacket(BasePacket): @@ -156,7 +156,7 @@ class NicknameResultPacket(BasePacket): class WorldItem(BaseModel): id: str - type: Literal["radio_station", "dice", "wheel", "clock"] + type: Literal["radio_station", "dice", "wheel", "clock", "widget"] title: str x: int y: int @@ -174,7 +174,7 @@ class WorldItem(BaseModel): class PersistedWorldItem(BaseModel): model_config = ConfigDict(extra="ignore") id: str - type: Literal["radio_station", "dice", "wheel", "clock"] + type: Literal["radio_station", "dice", "wheel", "clock", "widget"] title: str x: int y: int diff --git a/server/app/server.py b/server/app/server.py index d32bf07..893a2b3 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -117,6 +117,20 @@ class SignalingServer: return "radio" if item.type == "radio_station" else item.type + @staticmethod + def _resolve_item_use_sound(item: WorldItem) -> str | None: + """Resolve one-shot use sound, preferring per-item param override.""" + + param_sound = item.params.get("useSound") + if isinstance(param_sound, str): + token = param_sound.strip() + if token: + return token + return None + if isinstance(item.useSound, str) and item.useSound.strip(): + return item.useSound.strip() + return None + def _is_in_bounds(self, x: int, y: int) -> bool: """Check whether a coordinate is inside server-authoritative world bounds.""" @@ -590,12 +604,13 @@ class SignalingServer: BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True), exclude=client.websocket, ) - if item.useSound: + use_sound = self._resolve_item_use_sound(item) + if use_sound: await self._broadcast( ItemUseSoundPacket( type="item_use_sound", itemId=item.id, - sound=item.useSound, + sound=use_sound, x=item.x, y=item.y, ) diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 1d4b6a7..7b54136 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -242,3 +242,62 @@ async def test_failed_wheel_use_does_not_consume_cooldown(monkeypatch: pytest.Mo item.params["spaces"] = "a,b,c" await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) assert send_payloads[-1].ok is True + + +@pytest.mark.asyncio +async def test_widget_update_and_use(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 + item = server.item_service.default_item(client, "widget") + server.item_service.add_item(item) + + send_payloads: list[object] = [] + broadcast_payloads: list[object] = [] + now_ms = 50_000 + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_payloads.append(packet) + + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server.item_service, "now_ms", lambda: now_ms) + + await server._handle_message( + client, + json.dumps( + { + "type": "item_update", + "itemId": item.id, + "params": { + "directional": True, + "facing": 123.4, + "emitRange": 7, + "useSound": "ping.ogg", + "emitSound": "https://example.com/ambient.ogg", + }, + } + ), + ) + assert send_payloads[-1].ok is True + assert item.params.get("directional") is True + assert item.params.get("facing") == 123.4 + assert item.params.get("emitRange") == 7 + assert item.params.get("useSound") == "sounds/ping.ogg" + assert item.params.get("emitSound") == "https://example.com/ambient.ogg" + + await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) + assert send_payloads[-1].ok is True + assert item.params.get("enabled") is False + assert any(getattr(packet, "type", "") == "item_use_sound" for packet in broadcast_payloads) + + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 21}}), + ) + assert send_payloads[-1].ok is False + assert "emitrange must be between 1 and 20" in send_payloads[-1].message.lower()