Add clock item type with timezone/time-format and emit sound

This commit is contained in:
Jage9
2026-02-21 16:01:40 -05:00
parent b52f9b7862
commit b2c3f75ae3
13 changed files with 218 additions and 40 deletions

Binary file not shown.

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 R97"; window.CHGRID_WEB_VERSION = "2026.02.21 R98";
// 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

@@ -105,14 +105,21 @@ type ChangelogData = {
const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim(); const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
const DISPLAY_TIME_ZONE = resolveDisplayTimeZone(); const DISPLAY_TIME_ZONE = resolveDisplayTimeZone();
const CLOCK_TIME_ZONE_OPTIONS = [
'America/Detroit',
'America/New_York',
'America/Indiana/Indianapolis',
'America/Kentucky/Louisville',
] as const;
dom.appVersion.textContent = APP_VERSION dom.appVersion.textContent = APP_VERSION
? `Another AI experiment with Jage. Version ${APP_VERSION}` ? `Another AI experiment with Jage. Version ${APP_VERSION}`
: 'Another AI experiment with Jage. Version unknown'; : 'Another AI experiment with Jage. Version unknown';
const ITEM_TYPE_SEQUENCE: ItemType[] = ['radio_station', 'dice', 'wheel']; const ITEM_TYPE_SEQUENCE: ItemType[] = ['radio_station', 'dice', 'wheel', 'clock'];
const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = { const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
radio_station: { useCooldownMs: 1000 }, radio_station: { emitSound: 'none', useCooldownMs: 1000 },
dice: { useCooldownMs: 1000 }, dice: { emitSound: 'sounds/roll.ogg', useCooldownMs: 1000 },
wheel: { useCooldownMs: 4000 }, wheel: { emitSound: 'sounds/spin.ogg', useCooldownMs: 4000 },
clock: { emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
}; };
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([ const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
'title', 'title',
@@ -125,10 +132,14 @@ const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
'spaces', 'spaces',
'sides', 'sides',
'number', 'number',
'timeZone',
'use24Hour',
]); ]);
const OPTION_ITEM_PROPERTY_VALUES: Partial<Record<string, string[]>> = { const OPTION_ITEM_PROPERTY_VALUES: Partial<Record<string, string[]>> = {
effect: EFFECT_SEQUENCE.map((effect) => effect.id), effect: EFFECT_SEQUENCE.map((effect) => effect.id),
channel: [...RADIO_CHANNEL_OPTIONS], channel: [...RADIO_CHANNEL_OPTIONS],
timeZone: [...CLOCK_TIME_ZONE_OPTIONS],
use24Hour: ['off', 'on'],
}; };
const APP_BASE_URL = import.meta.env.BASE_URL || '/'; const APP_BASE_URL = import.meta.env.BASE_URL || '/';
function withBase(path: string): string { function withBase(path: string): string {
@@ -452,6 +463,8 @@ function getEditableItemPropertyKeys(item: WorldItem): string[] {
keys.push('sides', 'number'); keys.push('sides', 'number');
} else if (item.type === 'wheel') { } else if (item.type === 'wheel') {
keys.push('spaces'); keys.push('spaces');
} else if (item.type === 'clock') {
keys.push('timeZone', 'use24Hour');
} }
return keys; return keys;
} }
@@ -461,7 +474,7 @@ function getInspectItemPropertyKeys(item: WorldItem): string[] {
const seen = new Set(editableKeys); const seen = new Set(editableKeys);
const allKeys: string[] = [...editableKeys]; const allKeys: string[] = [...editableKeys];
const baseKeys = ['type', 'x', 'y', 'carrierId', 'version', 'createdBy', 'createdAt', 'updatedAt', 'capabilities', 'useSound']; const baseKeys = ['type', 'x', 'y', 'carrierId', 'version', 'createdBy', 'createdAt', 'updatedAt', 'capabilities', 'emitSound'];
for (const key of baseKeys) { for (const key of baseKeys) {
if (seen.has(key)) continue; if (seen.has(key)) continue;
seen.add(key); seen.add(key);
@@ -617,8 +630,10 @@ 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 === 'emitSound') return item.emitSound ?? 'none';
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
if (key === 'timeZone') return String(item.params.timeZone ?? CLOCK_TIME_ZONE_OPTIONS[0]);
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
if (key === 'channel') return normalizeRadioChannel(item.params.channel); if (key === 'channel') return normalizeRadioChannel(item.params.channel);
if (key === 'effect') return normalizeRadioEffect(item.params.effect); if (key === 'effect') return normalizeRadioEffect(item.params.effect);
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue)); if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
@@ -1028,7 +1043,7 @@ async function onMessage(message: IncomingMessage): Promise<void> {
if (message.action === 'use') { if (message.action === 'use') {
pushChatMessage(message.message); pushChatMessage(message.message);
const item = message.itemId ? state.items.get(message.itemId) : null; const item = message.itemId ? state.items.get(message.itemId) : null;
if (!item?.useSound && item) { if (!item?.emitSound && item) {
audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y }); audio.sfxLocate({ x: item.x - state.player.x, y: item.y - state.player.y });
} }
} else if (message.action !== 'update') { } else if (message.action !== 'update') {

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']), type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
title: z.string(), title: z.string(),
x: z.number().int(), x: z.number().int(),
y: z.number().int(), y: z.number().int(),
@@ -11,7 +11,7 @@ export const itemSchema = z.object({
updatedAt: z.number().int(), updatedAt: z.number().int(),
version: z.number().int(), version: z.number().int(),
capabilities: z.array(z.string()), capabilities: z.array(z.string()),
useSound: z.string().optional(), emitSound: z.string().optional(),
params: z.record(z.string(), z.unknown()), params: z.record(z.string(), z.unknown()),
carrierId: z.string().nullable().optional(), carrierId: z.string().nullable().optional(),
}); });
@@ -129,7 +129,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' } | { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' }
| { 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

@@ -76,11 +76,22 @@ export class CanvasRenderer {
private drawItem(item: WorldItem): void { private drawItem(item: WorldItem): void {
const drawX = item.x * this.squarePixelSize; const drawX = item.x * this.squarePixelSize;
const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize; const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize;
this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : item.type === 'wheel' ? '#f97316' : '#60a5fa'; this.ctx.fillStyle =
item.type === 'radio_station'
? '#fbbf24'
: item.type === 'wheel'
? '#f97316'
: item.type === 'clock'
? '#86efac'
: '#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(item.type === 'radio_station' ? 'R' : item.type === 'wheel' ? 'W' : 'D', drawX + this.squarePixelSize / 2, drawY + 13); this.ctx.fillText(
item.type === 'radio_station' ? 'R' : item.type === 'wheel' ? 'W' : item.type === 'clock' ? 'C' : 'D',
drawX + this.squarePixelSize / 2,
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'; export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock';
export type WorldItem = { export type WorldItem = {
id: string; id: string;
@@ -15,7 +15,7 @@ export type WorldItem = {
updatedAt: number; updatedAt: number;
version: number; version: number;
capabilities: string[]; capabilities: string[];
useSound?: string; emitSound?: string;
params: Record<string, unknown>; params: Record<string, unknown>;
carrierId?: string | null; carrierId?: string | null;
}; };

View File

@@ -5,7 +5,7 @@
```json ```json
{ {
"id": "string", "id": "string",
"type": "radio_station | dice | wheel", "type": "radio_station | dice | wheel | clock",
"title": "string", "title": "string",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -14,22 +14,22 @@
"updatedAt": 1735689600000, "updatedAt": 1735689600000,
"version": 1, "version": 1,
"capabilities": ["editable", "carryable", "deletable", "usable"], "capabilities": ["editable", "carryable", "deletable", "usable"],
"useSound": "sounds/roll.ogg", "emitSound": "sounds/roll.ogg",
"params": {}, "params": {},
"carrierId": null "carrierId": null
} }
``` ```
- `useSound`: optional client-played sound path when item `use` succeeds; global item field and not user-editable in V1. - `emitSound`: optional client-played sound path when item `use` succeeds; global item field and not user-editable in V1.
- `capabilities` and `useSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state). - `capabilities` 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`), not per-instance editable. - `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`), not per-instance editable.
## 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", "type": "radio_station | dice | wheel | clock",
"title": "string", "title": "string",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -94,6 +94,18 @@
- max 100 values - max 100 values
- each value max 80 chars - each value max 80 chars
### `clock`
```json
{
"timeZone": "America/Detroit",
"use24Hour": false
}
```
- `timeZone`: one of `America/Detroit | America/New_York | America/Indiana/Indianapolis | America/Kentucky/Louisville`.
- `use24Hour`: boolean (or `on/off` in updates), default `false`.
## Packet Shapes ## Packet Shapes
- `item_upsert`: - `item_upsert`:

View File

@@ -3,14 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
ItemType = Literal["radio_station", "dice", "wheel"] ItemType = Literal["radio_station", "dice", "wheel", "clock"]
CLOCK_DEFAULT_TIME_ZONE = "America/Detroit"
CLOCK_TIME_ZONE_OPTIONS: tuple[str, ...] = (
"America/Detroit",
"America/New_York",
"America/Indiana/Indianapolis",
"America/Kentucky/Louisville",
)
@dataclass(frozen=True) @dataclass(frozen=True)
class ItemDefinition: class ItemDefinition:
default_title: str default_title: str
capabilities: tuple[str, ...] capabilities: tuple[str, ...]
use_sound: str | None emit_sound: str | None
default_params: dict default_params: dict
use_cooldown_ms: int = 1000 use_cooldown_ms: int = 1000
@@ -19,22 +26,28 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
"radio_station": ItemDefinition( "radio_station": ItemDefinition(
default_title="radio", default_title="radio",
capabilities=("editable", "carryable", "deletable", "usable"), capabilities=("editable", "carryable", "deletable", "usable"),
use_sound=None, emit_sound=None,
default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50}, default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50},
), ),
"dice": ItemDefinition( "dice": ItemDefinition(
default_title="Dice", default_title="Dice",
capabilities=("editable", "carryable", "deletable", "usable"), capabilities=("editable", "carryable", "deletable", "usable"),
use_sound="sounds/roll.ogg", emit_sound="sounds/roll.ogg",
default_params={"sides": 6, "number": 2}, default_params={"sides": 6, "number": 2},
), ),
"wheel": ItemDefinition( "wheel": ItemDefinition(
default_title="wheel", default_title="wheel",
capabilities=("editable", "carryable", "deletable", "usable"), capabilities=("editable", "carryable", "deletable", "usable"),
use_sound="sounds/spin.ogg", emit_sound="sounds/spin.ogg",
default_params={"spaces": "yes, no"}, default_params={"spaces": "yes, no"},
use_cooldown_ms=4000, use_cooldown_ms=4000,
), ),
"clock": ItemDefinition(
default_title="clock",
capabilities=("editable", "carryable", "deletable", "usable"),
emit_sound="sounds/clock.ogg",
default_params={"timeZone": CLOCK_DEFAULT_TIME_ZONE, "use24Hour": False},
),
} }

View File

@@ -25,7 +25,7 @@ class ItemService:
def now_ms() -> int: def now_ms() -> int:
return int(time.time() * 1000) return int(time.time() * 1000)
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel"]) -> WorldItem: def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock"]) -> WorldItem:
item_def = get_item_definition(item_type) item_def = get_item_definition(item_type)
now = self.now_ms() now = self.now_ms()
return WorldItem( return WorldItem(
@@ -39,7 +39,7 @@ class ItemService:
updatedAt=now, updatedAt=now,
version=1, version=1,
capabilities=list(item_def.capabilities), capabilities=list(item_def.capabilities),
useSound=item_def.use_sound, emitSound=item_def.emit_sound,
params=deepcopy(item_def.default_params), params=deepcopy(item_def.default_params),
carrierId=None, carrierId=None,
) )
@@ -95,7 +95,7 @@ class ItemService:
updatedAt=persisted.updatedAt, updatedAt=persisted.updatedAt,
version=persisted.version, version=persisted.version,
capabilities=list(item_def.capabilities), capabilities=list(item_def.capabilities),
useSound=item_def.use_sound, emitSound=item_def.emit_sound,
params=persisted.params, params=persisted.params,
carrierId=persisted.carrierId, carrierId=persisted.carrierId,
) )

View File

@@ -40,7 +40,7 @@ class PingPacket(BasePacket):
class ItemAddPacket(BasePacket): class ItemAddPacket(BasePacket):
type: Literal["item_add"] type: Literal["item_add"]
itemType: Literal["radio_station", "dice", "wheel"] itemType: Literal["radio_station", "dice", "wheel", "clock"]
class ItemPickupPacket(BasePacket): class ItemPickupPacket(BasePacket):
@@ -152,7 +152,7 @@ class NicknameResultPacket(BasePacket):
class WorldItem(BaseModel): class WorldItem(BaseModel):
id: str id: str
type: Literal["radio_station", "dice", "wheel"] type: Literal["radio_station", "dice", "wheel", "clock"]
title: str title: str
x: int x: int
y: int y: int
@@ -161,7 +161,7 @@ class WorldItem(BaseModel):
updatedAt: int updatedAt: int
version: int version: int
capabilities: list[str] capabilities: list[str]
useSound: str | None = None emitSound: str | None = None
params: dict params: dict
carrierId: str | None = None carrierId: str | None = None
@@ -169,7 +169,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"] type: Literal["radio_station", "dice", "wheel", "clock"]
title: str title: str
x: int x: int
y: int y: int

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse import argparse
import asyncio import asyncio
from datetime import datetime
import json import json
import logging import logging
import random import random
@@ -9,13 +10,14 @@ import ssl
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from zoneinfo import ZoneInfo
from pydantic import ValidationError, TypeAdapter from pydantic import ValidationError, TypeAdapter
from websockets.asyncio.server import ServerConnection, serve from websockets.asyncio.server import ServerConnection, serve
from .client import ClientConnection from .client import ClientConnection
from .config import load_config from .config import load_config
from .item_catalog import get_item_use_cooldown_ms from .item_catalog import CLOCK_DEFAULT_TIME_ZONE, CLOCK_TIME_ZONE_OPTIONS, get_item_use_cooldown_ms
from .item_service import ItemService from .item_service import ItemService
from .models import ( from .models import (
BroadcastChatMessagePacket, BroadcastChatMessagePacket,
@@ -90,6 +92,39 @@ class SignalingServer:
def _item_type_label(item: WorldItem) -> str: def _item_type_label(item: WorldItem) -> str:
return "radio" if item.type == "radio_station" else item.type return "radio" if item.type == "radio_station" else item.type
@staticmethod
def _normalize_clock_timezone(value: object) -> str:
token = str(value or "").strip()
if token in CLOCK_TIME_ZONE_OPTIONS:
return token
return CLOCK_DEFAULT_TIME_ZONE
@staticmethod
def _parse_clock_use_24_hour(value: object) -> bool | None:
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
@classmethod
def _format_clock_display_time(cls, params: dict) -> str:
tz_name = cls._normalize_clock_timezone(params.get("timeZone"))
use_24_hour = cls._parse_clock_use_24_hour(params.get("use24Hour"))
if use_24_hour is None:
use_24_hour = False
now = datetime.now(ZoneInfo(tz_name))
if use_24_hour:
return now.strftime("%H:%M")
hour_12 = now.hour % 12 or 12
return f"{hour_12}:{now.minute:02d} {'AM' if now.hour < 12 else 'PM'}"
async def _send_item_result( async def _send_item_result(
self, self,
client: ClientConnection, client: ClientConnection,
@@ -432,7 +467,7 @@ class SignalingServer:
if item.carrierId is None and (item.x != client.x or item.y != client.y): 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) await self._send_item_result(client, False, "use", "Item is not on your square.", item.id)
return return
if item.type not in {"radio_station", "dice", "wheel"}: 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) await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id)
return return
now_ms = self.item_service.now_ms() now_ms = self.item_service.now_ms()
@@ -483,7 +518,7 @@ class SignalingServer:
f"{client.nickname} rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})." 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})." self_message = f"You rolled {item.title}: {', '.join(str(value) for value in rolls)} (total {total})."
else: elif item.type == "wheel":
spaces_raw = item.params.get("spaces", "") spaces_raw = item.params.get("spaces", "")
if isinstance(spaces_raw, str): if isinstance(spaces_raw, str):
spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()] spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()]
@@ -505,16 +540,20 @@ class SignalingServer:
self_message = f"You spin {item.title}." self_message = f"You spin {item.title}."
delayed_wheel_self_result = str(landed) delayed_wheel_self_result = str(landed)
delayed_wheel_others_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}."
await self._broadcast( await self._broadcast(
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True), BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
exclude=client.websocket, exclude=client.websocket,
) )
if item.useSound: if item.emitSound:
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=item.emitSound,
x=item.x, x=item.x,
y=item.y, y=item.y,
) )
@@ -665,6 +704,29 @@ class SignalingServer:
) )
return return
next_params["effectValue"] = round(effect_value, 1) 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
item.params = next_params item.params = next_params
item.updatedAt = self.item_service.now_ms() item.updatedAt = self.item_service.now_ms()
item.version += 1 item.version += 1

View File

@@ -27,9 +27,9 @@ def test_item_persistence_omits_global_type_properties(tmp_path: Path) -> None:
assert isinstance(saved, list) assert isinstance(saved, list)
assert len(saved) == 1 assert len(saved) == 1
assert "capabilities" not in saved[0] assert "capabilities" not in saved[0]
assert "useSound" not in saved[0] assert "emitSound" not in saved[0]
reloaded = ItemService(state_file=state_file) reloaded = ItemService(state_file=state_file)
loaded_item = reloaded.items[item.id] loaded_item = reloaded.items[item.id]
assert loaded_item.useSound == "sounds/roll.ogg" assert loaded_item.emitSound == "sounds/roll.ogg"
assert "usable" in loaded_item.capabilities assert "usable" in loaded_item.capabilities

View File

@@ -117,3 +117,68 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) -
) )
assert send_payloads[-1].ok is False assert send_payloads[-1].ok is False
assert "channel must be one of" in send_payloads[-1].message.lower() assert "channel must be one of" in send_payloads[-1].message.lower()
@pytest.mark.asyncio
async def test_clock_use_reports_time_and_emits_sound(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, "clock")
server.item_service.add_item(item)
send_payloads: list[object] = []
broadcast_payloads: list[object] = []
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: 30_000)
monkeypatch.setattr(server, "_format_clock_display_time", lambda _params: "2:15 PM")
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
assert send_payloads[-1].ok is True
assert send_payloads[-1].message == f"{item.title} says 2:15 PM."
assert any(getattr(packet, "type", "") == "item_use_sound" for packet in broadcast_payloads)
@pytest.mark.asyncio
async def test_clock_timezone_update_validates(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, "clock")
server.item_service.add_item(item)
send_payloads: list[object] = []
async def fake_send(websocket: ServerConnection, packet: object) -> None:
send_payloads.append(packet)
async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None:
return
monkeypatch.setattr(server, "_send", fake_send)
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
await server._handle_message(
client,
json.dumps({"type": "item_update", "itemId": item.id, "params": {"timeZone": "America/New_York"}}),
)
assert send_payloads[-1].ok is True
assert item.params.get("timeZone") == "America/New_York"
await server._handle_message(
client,
json.dumps({"type": "item_update", "itemId": item.id, "params": {"timeZone": "Invalid/Zone"}}),
)
assert send_payloads[-1].ok is False
assert "timezone must be one of" in send_payloads[-1].message.lower()