Add clock item type with timezone/time-format and emit sound
This commit is contained in:
BIN
client/public/sounds/clock.ogg
Normal file
BIN
client/public/sounds/clock.ogg
Normal file
Binary file not shown.
@@ -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";
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
@@ -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},
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user