From b2c3f75ae3a2e9f64b2803c7f9a04e9c968f9790 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 16:01:40 -0500 Subject: [PATCH] Add clock item type with timezone/time-format and emit sound --- client/public/sounds/clock.ogg | Bin 0 -> 6211 bytes client/public/version.js | 2 +- client/src/main.ts | 29 +++++++--- client/src/network/protocol.ts | 6 +-- client/src/render/canvasRenderer.ts | 15 +++++- client/src/state/gameState.ts | 4 +- docs/item-schema.md | 24 ++++++--- server/app/item_catalog.py | 23 ++++++-- server/app/item_service.py | 6 +-- server/app/models.py | 8 +-- server/app/server.py | 72 +++++++++++++++++++++++-- server/tests/test_item_persistence.py | 4 +- server/tests/test_item_use_cooldown.py | 65 ++++++++++++++++++++++ 13 files changed, 218 insertions(+), 40 deletions(-) create mode 100644 client/public/sounds/clock.ogg diff --git a/client/public/sounds/clock.ogg b/client/public/sounds/clock.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d68f854c6571c7eedd3bfea1786a6a2bec73437f GIT binary patch literal 6211 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 Ue|wkzYti2mL_$RG^8ZiyAHzUP8UO$Q literal 0 HcmV?d00001 diff --git a/client/public/version.js b/client/public/version.js index 7760b08..7ac6018 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.21 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"; diff --git a/client/src/main.ts b/client/src/main.ts index b14e4fd..4039170 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -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> = { - 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> = { 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 { 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') { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 22eaea7..a2e46e7 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; export const itemSchema = z.object({ id: z.string(), - type: z.enum(['radio_station', 'dice', 'wheel']), + 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 } diff --git a/client/src/render/canvasRenderer.ts b/client/src/render/canvasRenderer.ts index 88c9c99..cdfa742 100644 --- a/client/src/render/canvasRenderer.ts +++ b/client/src/render/canvasRenderer.ts @@ -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, + ); } } diff --git a/client/src/state/gameState.ts b/client/src/state/gameState.ts index 1af7485..e09f6a6 100644 --- a/client/src/state/gameState.ts +++ b/client/src/state/gameState.ts @@ -2,7 +2,7 @@ export const GRID_SIZE = 41; export const HEARING_RADIUS = 15; export const MOVE_COOLDOWN_MS = 200; -export type ItemType = 'radio_station' | 'dice' | 'wheel'; +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; carrierId?: string | null; }; diff --git a/docs/item-schema.md b/docs/item-schema.md index 2e58bd2..8addd55 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -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`: diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 087fd6a..e82faad 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -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}, + ), } diff --git a/server/app/item_service.py b/server/app/item_service.py index bac0377..ad23b73 100644 --- a/server/app/item_service.py +++ b/server/app/item_service.py @@ -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, ) diff --git a/server/app/models.py b/server/app/models.py index 0affb97..4336440 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -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 diff --git a/server/app/server.py b/server/app/server.py index 6c0289c..6d6e69b 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -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 diff --git a/server/tests/test_item_persistence.py b/server/tests/test_item_persistence.py index 7579bc8..9954068 100644 --- a/server/tests/test_item_persistence.py +++ b/server/tests/test_item_persistence.py @@ -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 diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 82e34e7..22d9347 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -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()