Add widget item type with editable sound and spatial controls
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ItemType, string[]> = {
|
||||
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<ItemType, Record<string, string | number | boolean>> = {
|
||||
@@ -59,6 +60,7 @@ const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, strin
|
||||
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false },
|
||||
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false },
|
||||
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false },
|
||||
widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false },
|
||||
};
|
||||
|
||||
export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
|
||||
@@ -92,6 +94,7 @@ let itemTypeLabels: Record<ItemType, string> = {
|
||||
dice: 'dice',
|
||||
wheel: 'wheel',
|
||||
clock: 'clock',
|
||||
widget: 'widget',
|
||||
};
|
||||
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
||||
let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
||||
@@ -99,12 +102,14 @@ let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
||||
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<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 },
|
||||
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
|
||||
};
|
||||
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, ...]] = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
107
server/app/items/widget.py
Normal file
107
server/app/items/widget.py
Normal file
@@ -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},
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user