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.
// 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.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -105,14 +105,21 @@ type ChangelogData = {
const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim();
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
? `Another AI experiment with Jage. Version ${APP_VERSION}`
: '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>> = {
radio_station: { useCooldownMs: 1000 },
dice: { useCooldownMs: 1000 },
wheel: { useCooldownMs: 4000 },
radio_station: { emitSound: 'none', useCooldownMs: 1000 },
dice: { emitSound: 'sounds/roll.ogg', useCooldownMs: 1000 },
wheel: { emitSound: 'sounds/spin.ogg', useCooldownMs: 4000 },
clock: { emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
};
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
'title',
@@ -125,10 +132,14 @@ const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
'spaces',
'sides',
'number',
'timeZone',
'use24Hour',
]);
const OPTION_ITEM_PROPERTY_VALUES: Partial<Record<string, string[]>> = {
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
channel: [...RADIO_CHANNEL_OPTIONS],
timeZone: [...CLOCK_TIME_ZONE_OPTIONS],
use24Hour: ['off', 'on'],
};
const APP_BASE_URL = import.meta.env.BASE_URL || '/';
function withBase(path: string): string {
@@ -452,6 +463,8 @@ function getEditableItemPropertyKeys(item: WorldItem): string[] {
keys.push('sides', 'number');
} else if (item.type === 'wheel') {
keys.push('spaces');
} else if (item.type === 'clock') {
keys.push('timeZone', 'use24Hour');
}
return keys;
}
@@ -461,7 +474,7 @@ function getInspectItemPropertyKeys(item: WorldItem): string[] {
const seen = new Set(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) {
if (seen.has(key)) continue;
seen.add(key);
@@ -617,8 +630,10 @@ 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 === '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 === 'effect') return normalizeRadioEffect(item.params.effect);
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
@@ -1028,7 +1043,7 @@ async function onMessage(message: IncomingMessage): Promise<void> {
if (message.action === 'use') {
pushChatMessage(message.message);
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 });
}
} else if (message.action !== 'update') {

View File

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

View File

@@ -76,11 +76,22 @@ export class CanvasRenderer {
private drawItem(item: WorldItem): void {
const drawX = item.x * 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.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' : '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 MOVE_COOLDOWN_MS = 200;
export type ItemType = 'radio_station' | 'dice' | 'wheel';
export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock';
export type WorldItem = {
id: string;
@@ -15,7 +15,7 @@ export type WorldItem = {
updatedAt: number;
version: number;
capabilities: string[];
useSound?: string;
emitSound?: string;
params: Record<string, unknown>;
carrierId?: string | null;
};

View File

@@ -5,7 +5,7 @@
```json
{
"id": "string",
"type": "radio_station | dice | wheel",
"type": "radio_station | dice | wheel | clock",
"title": "string",
"x": 0,
"y": 0,
@@ -14,22 +14,22 @@
"updatedAt": 1735689600000,
"version": 1,
"capabilities": ["editable", "carryable", "deletable", "usable"],
"useSound": "sounds/roll.ogg",
"emitSound": "sounds/roll.ogg",
"params": {},
"carrierId": null
}
```
- `useSound`: 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).
- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`), not per-instance editable.
- `emitSound`: optional client-played sound path when item `use` succeeds; global item field and not user-editable in V1.
- `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`, `clock=1000`), not per-instance editable.
## Persisted Item State (`server/runtime/items.json`)
```json
{
"id": "string",
"type": "radio_station | dice | wheel",
"type": "radio_station | dice | wheel | clock",
"title": "string",
"x": 0,
"y": 0,
@@ -94,6 +94,18 @@
- max 100 values
- 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
- `item_upsert`:

View File

@@ -3,14 +3,21 @@ from __future__ import annotations
from dataclasses import dataclass
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)
class ItemDefinition:
default_title: str
capabilities: tuple[str, ...]
use_sound: str | None
emit_sound: str | None
default_params: dict
use_cooldown_ms: int = 1000
@@ -19,22 +26,28 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
"radio_station": ItemDefinition(
default_title="radio",
capabilities=("editable", "carryable", "deletable", "usable"),
use_sound=None,
emit_sound=None,
default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50},
),
"dice": ItemDefinition(
default_title="Dice",
capabilities=("editable", "carryable", "deletable", "usable"),
use_sound="sounds/roll.ogg",
emit_sound="sounds/roll.ogg",
default_params={"sides": 6, "number": 2},
),
"wheel": ItemDefinition(
default_title="wheel",
capabilities=("editable", "carryable", "deletable", "usable"),
use_sound="sounds/spin.ogg",
emit_sound="sounds/spin.ogg",
default_params={"spaces": "yes, no"},
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:
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)
now = self.now_ms()
return WorldItem(
@@ -39,7 +39,7 @@ class ItemService:
updatedAt=now,
version=1,
capabilities=list(item_def.capabilities),
useSound=item_def.use_sound,
emitSound=item_def.emit_sound,
params=deepcopy(item_def.default_params),
carrierId=None,
)
@@ -95,7 +95,7 @@ class ItemService:
updatedAt=persisted.updatedAt,
version=persisted.version,
capabilities=list(item_def.capabilities),
useSound=item_def.use_sound,
emitSound=item_def.emit_sound,
params=persisted.params,
carrierId=persisted.carrierId,
)

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
from datetime import datetime
import json
import logging
import random
@@ -9,13 +10,14 @@ import ssl
import uuid
from pathlib import Path
from typing import Literal
from zoneinfo import ZoneInfo
from pydantic import ValidationError, TypeAdapter
from websockets.asyncio.server import ServerConnection, serve
from .client import ClientConnection
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 .models import (
BroadcastChatMessagePacket,
@@ -90,6 +92,39 @@ class SignalingServer:
def _item_type_label(item: WorldItem) -> str:
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(
self,
client: ClientConnection,
@@ -432,7 +467,7 @@ class SignalingServer:
if item.carrierId is None and (item.x != client.x or item.y != client.y):
await self._send_item_result(client, False, "use", "Item is not on your square.", item.id)
return
if item.type not in {"radio_station", "dice", "wheel"}:
if item.type not in {"radio_station", "dice", "wheel", "clock"}:
await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id)
return
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})."
)
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", "")
if isinstance(spaces_raw, str):
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}."
delayed_wheel_self_result = str(landed)
delayed_wheel_others_result = str(landed)
else:
display_time = self._format_clock_display_time(item.params)
others_message = f"{client.nickname} checks {item.title}. {item.title} says {display_time}."
self_message = f"{item.title} says {display_time}."
await self._broadcast(
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
exclude=client.websocket,
)
if item.useSound:
if item.emitSound:
await self._broadcast(
ItemUseSoundPacket(
type="item_use_sound",
itemId=item.id,
sound=item.useSound,
sound=item.emitSound,
x=item.x,
y=item.y,
)
@@ -665,6 +704,29 @@ class SignalingServer:
)
return
next_params["effectValue"] = round(effect_value, 1)
if item.type == "clock":
time_zone = str(next_params.get("timeZone", CLOCK_DEFAULT_TIME_ZONE)).strip()
if time_zone not in CLOCK_TIME_ZONE_OPTIONS:
await self._send_item_result(
client,
False,
"update",
f"timeZone must be one of {', '.join(CLOCK_TIME_ZONE_OPTIONS)}.",
item.id,
)
return
use_24_hour = self._parse_clock_use_24_hour(next_params.get("use24Hour"))
if use_24_hour is None:
await self._send_item_result(
client,
False,
"update",
"use24Hour must be on/off.",
item.id,
)
return
next_params["timeZone"] = time_zone
next_params["use24Hour"] = use_24_hour
item.params = next_params
item.updatedAt = self.item_service.now_ms()
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 len(saved) == 1
assert "capabilities" not in saved[0]
assert "useSound" not in saved[0]
assert "emitSound" not in saved[0]
reloaded = ItemService(state_file=state_file)
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

View File

@@ -117,3 +117,68 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) -
)
assert send_payloads[-1].ok is False
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()