Add widget item type with editable sound and spatial controls

This commit is contained in:
Jage9
2026-02-21 22:20:15 -05:00
parent 97caaef001
commit bb36a007e2
16 changed files with 309 additions and 27 deletions

View File

@@ -1,5 +1,5 @@
// Maintainer-controlled web client version. // Maintainer-controlled web client version.
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) // 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. // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit"; window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -65,7 +65,9 @@ export class ItemEmitRuntime {
if (!audioCtx) return; if (!audioCtx) return;
for (const item of items) { 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) { if (!soundUrl || item.carrierId) {
this.cleanup(item.id); this.cleanup(item.id);
continue; continue;

View File

@@ -45,13 +45,14 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
'UTC', 'UTC',
] as const; ] 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[]> = { const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'], radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'],
dice: ['title', 'sides', 'number'], dice: ['title', 'sides', 'number'],
wheel: ['title', 'spaces'], wheel: ['title', 'spaces'],
clock: ['title', 'timeZone', 'use24Hour'], 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>> = { 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 }, 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 }, 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 }, 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'; export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
@@ -92,6 +94,7 @@ let itemTypeLabels: Record<ItemType, string> = {
dice: 'dice', dice: 'dice',
wheel: 'wheel', wheel: 'wheel',
clock: 'clock', clock: 'clock',
widget: 'widget',
}; };
let itemTypeTooltips: Partial<Record<ItemType, string>> = {}; let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
let itemTypeEditableProperties: Record<ItemType, string[]> = { let itemTypeEditableProperties: Record<ItemType, string[]> = {
@@ -99,12 +102,14 @@ let itemTypeEditableProperties: Record<ItemType, string[]> = {
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice], dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel], wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel],
clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock], clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock],
widget: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.widget],
}; };
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = { let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {
radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station }, radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station },
dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice }, dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice },
wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel }, wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel },
clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock }, clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock },
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
}; };
let optionItemPropertyValues: Partial<Record<string, string[]>> = { let optionItemPropertyValues: Partial<Record<string, string[]>> = {
effect: EFFECT_SEQUENCE.map((effect) => effect.id), effect: EFFECT_SEQUENCE.map((effect) => effect.id),
@@ -188,6 +193,8 @@ export function itemTypeLabel(type: ItemType): string {
export function itemPropertyLabel(key: string): string { export function itemPropertyLabel(key: string): string {
if (key === 'use24Hour') return 'use 24 hour format'; if (key === 'use24Hour') return 'use 24 hour format';
if (key === 'emitRange') return 'emit range'; if (key === 'emitRange') return 'emit range';
if (key === 'useSound') return 'use sound';
if (key === 'emitSound') return 'emit sound';
return key; return key;
} }

View File

@@ -531,7 +531,7 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
const rawGlobalRange = Number(global.emitRange); const rawGlobalRange = Number(global.emitRange);
const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange; const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange;
const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15; 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 rawFacing = Number(item.params.facing ?? 0);
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0; const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
return { range, directional, facingDeg }; return { range, directional, facingDeg };
@@ -694,6 +694,14 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
} }
function getItemPropertyValue(item: WorldItem, key: string): string { 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 === 'title') return item.title;
if (key === 'type') return item.type; if (key === 'type') return item.type;
if (key === 'x') return String(item.x); 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 === 'createdAt') return formatTimestampMs(item.createdAt);
if (key === 'updatedAt') return formatTimestampMs(item.updatedAt); if (key === 'updatedAt') return formatTimestampMs(item.updatedAt);
if (key === 'capabilities') return item.capabilities.join(', ') || 'none'; if (key === 'capabilities') return item.capabilities.join(', ') || 'none';
if (key === 'useSound') return item.useSound ?? 'none'; if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound);
if (key === 'emitSound') return item.emitSound ?? 'none'; if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound);
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; 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 === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone());
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off'; if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
if (key === 'channel') return normalizeRadioChannel(item.params.channel); if (key === 'channel') return normalizeRadioChannel(item.params.channel);
@@ -2033,6 +2047,13 @@ function handleItemPropertiesModeInput(code: string, key: string): void {
audio.sfxUiBlip(); audio.sfxUiBlip();
return; 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') { if (key === 'use24Hour') {
const nextUse24Hour = item.params.use24Hour !== true; const nextUse24Hour = item.params.use24Hour !== true;
signaling.send({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } }); 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); const enabled = ['on', 'true', '1', 'yes'].includes(normalized);
signaling.send({ type: 'item_update', itemId, params: { enabled } }); 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') { } else if (propertyKey === 'volume') {
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true); const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
if (!parsed.ok) { if (!parsed.ok) {
@@ -2148,6 +2178,8 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
return; return;
} }
signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed.value } }); 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') { } else if (propertyKey === 'spaces') {
const spaces = value const spaces = value
.split(',') .split(',')

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
export const itemSchema = z.object({ export const itemSchema = z.object({
id: z.string(), id: z.string(),
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']),
title: z.string(), title: z.string(),
x: z.number().int(), x: z.number().int(),
y: z.number().int(), y: z.number().int(),
@@ -36,10 +36,10 @@ export const welcomeMessageSchema = z.object({
.optional(), .optional(),
uiDefinitions: z uiDefinitions: z
.object({ .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( itemTypes: z.array(
z.object({ z.object({
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']), type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']),
label: z.string().optional(), label: z.string().optional(),
tooltip: z.string().optional(), tooltip: z.string().optional(),
editableProperties: z.array(z.string()), editableProperties: z.array(z.string()),
@@ -166,7 +166,7 @@ export type OutgoingMessage =
| { type: 'update_nickname'; nickname: string } | { type: 'update_nickname'; nickname: string }
| { type: 'chat_message'; message: string } | { type: 'chat_message'; message: string }
| { type: 'ping'; clientSentAt: number } | { 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_pickup'; itemId: string }
| { type: 'item_drop'; itemId: string; x: number; y: number } | { type: 'item_drop'; itemId: string; x: number; y: number }
| { type: 'item_delete'; itemId: string } | { type: 'item_delete'; itemId: string }

View File

@@ -91,13 +91,23 @@ export class CanvasRenderer {
? '#f97316' ? '#f97316'
: item.type === 'clock' : item.type === 'clock'
? '#86efac' ? '#86efac'
: item.type === 'widget'
? '#22d3ee'
: '#60a5fa'; : '#60a5fa';
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize); this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
this.ctx.fillStyle = '#111827'; this.ctx.fillStyle = '#111827';
this.ctx.font = 'bold 12px Courier New'; this.ctx.font = 'bold 12px Courier New';
this.ctx.textAlign = 'center'; this.ctx.textAlign = 'center';
this.ctx.fillText( 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, drawX + this.squarePixelSize / 2,
drawY + 13, drawY + 13,
); );

View File

@@ -2,7 +2,7 @@ export const GRID_SIZE = 41;
export const HEARING_RADIUS = 15; export const HEARING_RADIUS = 15;
export const MOVE_COOLDOWN_MS = 200; 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 = { export type WorldItem = {
id: string; id: string;

View File

@@ -5,7 +5,7 @@
```json ```json
{ {
"id": "string", "id": "string",
"type": "radio_station | dice | wheel | clock", "type": "radio_station | dice | wheel | clock | widget",
"title": "string", "title": "string",
"x": 0, "x": 0,
"y": 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. - `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. - `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). - `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. - `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`). - `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`). - `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`) ## Persisted Item State (`server/runtime/items.json`)
```json ```json
{ {
"id": "string", "id": "string",
"type": "radio_station | dice | wheel | clock", "type": "radio_station | dice | wheel | clock | widget",
"title": "string", "title": "string",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -128,6 +128,26 @@
- `use24Hour`: boolean (or `on/off` in updates), default `false`. - `use24Hour`: boolean (or `on/off` in updates), default `false`.
- Global defaults: `useSound=none`, `emitSound=sounds/clock.ogg`. - 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 ## Packet Shapes
- `item_upsert`: - `item_upsert`:

View File

@@ -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` - `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
- `use24Hour`: boolean or on/off style input - `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) ## 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. Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry.

View File

@@ -8,7 +8,7 @@ from typing import Literal, cast
from .items import clock, radio from .items import clock, radio
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER 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_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_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, ...]] = { ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {

View File

@@ -33,7 +33,7 @@ class ItemService:
return int(time.time() * 1000) 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.""" """Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type) item_def = get_item_definition(item_type)

View File

@@ -7,7 +7,7 @@ from typing import Callable, Protocol
from ..item_types import ItemUseResult from ..item_types import ItemUseResult
from ..models import WorldItem from ..models import WorldItem
from . import clock, dice, radio, wheel from . import clock, dice, radio, wheel, widget
class ItemModule(Protocol): class ItemModule(Protocol):
@@ -29,11 +29,12 @@ class ItemModule(Protocol):
use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult] 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] = { ITEM_MODULES: dict[str, ItemModule] = {
"clock": clock, "clock": clock,
"dice": dice, "dice": dice,
"radio_station": radio, "radio_station": radio,
"wheel": wheel, "wheel": wheel,
"widget": widget,
} }

107
server/app/items/widget.py Normal file
View 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},
)

View File

@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
class ItemAddPacket(BasePacket): class ItemAddPacket(BasePacket):
type: Literal["item_add"] type: Literal["item_add"]
itemType: Literal["radio_station", "dice", "wheel", "clock"] itemType: Literal["radio_station", "dice", "wheel", "clock", "widget"]
class ItemPickupPacket(BasePacket): class ItemPickupPacket(BasePacket):
@@ -156,7 +156,7 @@ class NicknameResultPacket(BasePacket):
class WorldItem(BaseModel): class WorldItem(BaseModel):
id: str id: str
type: Literal["radio_station", "dice", "wheel", "clock"] type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
title: str title: str
x: int x: int
y: int y: int
@@ -174,7 +174,7 @@ class WorldItem(BaseModel):
class PersistedWorldItem(BaseModel): class PersistedWorldItem(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str id: str
type: Literal["radio_station", "dice", "wheel", "clock"] type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
title: str title: str
x: int x: int
y: int y: int

View File

@@ -117,6 +117,20 @@ class SignalingServer:
return "radio" if item.type == "radio_station" else item.type 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: def _is_in_bounds(self, x: int, y: int) -> bool:
"""Check whether a coordinate is inside server-authoritative world bounds.""" """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), BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
exclude=client.websocket, exclude=client.websocket,
) )
if item.useSound: use_sound = self._resolve_item_use_sound(item)
if use_sound:
await self._broadcast( await self._broadcast(
ItemUseSoundPacket( ItemUseSoundPacket(
type="item_use_sound", type="item_use_sound",
itemId=item.id, itemId=item.id,
sound=item.useSound, sound=use_sound,
x=item.x, x=item.x,
y=item.y, y=item.y,
) )

View File

@@ -242,3 +242,62 @@ async def test_failed_wheel_use_does_not_consume_cooldown(monkeypatch: pytest.Mo
item.params["spaces"] = "a,b,c" item.params["spaces"] = "a,b,c"
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id})) await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
assert send_payloads[-1].ok is True 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()