Refactor item behavior into server/client registries
This commit is contained in:
135
client/src/items/itemRegistry.ts
Normal file
135
client/src/items/itemRegistry.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { EFFECT_SEQUENCE } from '../audio/effects';
|
||||||
|
import { RADIO_CHANNEL_OPTIONS } from '../audio/radioStationRuntime';
|
||||||
|
import { type ItemType, type WorldItem } from '../state/gameState';
|
||||||
|
|
||||||
|
export const CLOCK_TIME_ZONE_OPTIONS = [
|
||||||
|
'America/Anchorage',
|
||||||
|
'America/Argentina/Buenos_Aires',
|
||||||
|
'America/Chicago',
|
||||||
|
'America/Detroit',
|
||||||
|
'America/Halifax',
|
||||||
|
'America/Indiana/Indianapolis',
|
||||||
|
'America/Kentucky/Louisville',
|
||||||
|
'America/Los_Angeles',
|
||||||
|
'America/St_Johns',
|
||||||
|
'Asia/Bangkok',
|
||||||
|
'Asia/Dhaka',
|
||||||
|
'Asia/Dubai',
|
||||||
|
'Asia/Hong_Kong',
|
||||||
|
'Asia/Kabul',
|
||||||
|
'Asia/Karachi',
|
||||||
|
'Asia/Kathmandu',
|
||||||
|
'Asia/Kolkata',
|
||||||
|
'Asia/Seoul',
|
||||||
|
'Asia/Singapore',
|
||||||
|
'Asia/Tehran',
|
||||||
|
'Asia/Tokyo',
|
||||||
|
'Asia/Yangon',
|
||||||
|
'Atlantic/Azores',
|
||||||
|
'Atlantic/South_Georgia',
|
||||||
|
'Australia/Brisbane',
|
||||||
|
'Australia/Darwin',
|
||||||
|
'Australia/Eucla',
|
||||||
|
'Australia/Lord_Howe',
|
||||||
|
'Europe/Berlin',
|
||||||
|
'Europe/Helsinki',
|
||||||
|
'Europe/London',
|
||||||
|
'Europe/Moscow',
|
||||||
|
'Pacific/Apia',
|
||||||
|
'Pacific/Auckland',
|
||||||
|
'Pacific/Chatham',
|
||||||
|
'Pacific/Honolulu',
|
||||||
|
'Pacific/Kiritimati',
|
||||||
|
'Pacific/Noumea',
|
||||||
|
'Pacific/Pago_Pago',
|
||||||
|
'UTC',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
|
||||||
|
|
||||||
|
const ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||||
|
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue'],
|
||||||
|
dice: ['title', 'sides', 'number'],
|
||||||
|
wheel: ['title', 'spaces'],
|
||||||
|
clock: ['title', 'timeZone', 'use24Hour'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||||
|
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 },
|
||||||
|
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 },
|
||||||
|
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 },
|
||||||
|
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
|
||||||
|
Array.from(
|
||||||
|
new Set(
|
||||||
|
Object.values(ITEM_TYPE_EDITABLE_PROPERTIES).flatMap((keys) => keys),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getItemPropertyOptionValues(key: string): string[] | undefined {
|
||||||
|
return OPTION_ITEM_PROPERTY_VALUES[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemTypeLabel(type: ItemType): string {
|
||||||
|
if (type === 'radio_station') return 'radio';
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemPropertyLabel(key: string): string {
|
||||||
|
if (key === 'use24Hour') return 'use 24 hour format';
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEditableItemPropertyKeys(item: WorldItem): string[] {
|
||||||
|
return [...(ITEM_TYPE_EDITABLE_PROPERTIES[item.type] ?? ['title'])];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInspectItemPropertyKeys(item: WorldItem): string[] {
|
||||||
|
const editableKeys = getEditableItemPropertyKeys(item);
|
||||||
|
const seen = new Set(editableKeys);
|
||||||
|
const allKeys: string[] = [...editableKeys];
|
||||||
|
|
||||||
|
const baseKeys = [
|
||||||
|
'type',
|
||||||
|
'x',
|
||||||
|
'y',
|
||||||
|
'carrierId',
|
||||||
|
'version',
|
||||||
|
'createdBy',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
'capabilities',
|
||||||
|
'useSound',
|
||||||
|
'emitSound',
|
||||||
|
];
|
||||||
|
for (const key of baseKeys) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
allKeys.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b));
|
||||||
|
for (const key of paramKeys) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
allKeys.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
|
||||||
|
for (const key of globalKeys) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
allKeys.push(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allKeys;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
clampEffectLevel,
|
clampEffectLevel,
|
||||||
type EffectId,
|
type EffectId,
|
||||||
} from './audio/effects';
|
} from './audio/effects';
|
||||||
import { RADIO_CHANNEL_OPTIONS, RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
|
import { RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
|
||||||
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
||||||
import {
|
import {
|
||||||
applyPastedText,
|
applyPastedText,
|
||||||
@@ -29,9 +29,19 @@ import {
|
|||||||
getDirection,
|
getDirection,
|
||||||
getNearestItem,
|
getNearestItem,
|
||||||
getNearestPeer,
|
getNearestPeer,
|
||||||
type ItemType,
|
|
||||||
type WorldItem,
|
type WorldItem,
|
||||||
} from './state/gameState';
|
} from './state/gameState';
|
||||||
|
import {
|
||||||
|
CLOCK_TIME_ZONE_OPTIONS,
|
||||||
|
EDITABLE_ITEM_PROPERTY_KEYS,
|
||||||
|
ITEM_TYPE_GLOBAL_PROPERTIES,
|
||||||
|
ITEM_TYPE_SEQUENCE,
|
||||||
|
getEditableItemPropertyKeys,
|
||||||
|
getInspectItemPropertyKeys,
|
||||||
|
getItemPropertyOptionValues,
|
||||||
|
itemPropertyLabel,
|
||||||
|
itemTypeLabel,
|
||||||
|
} from './items/itemRegistry';
|
||||||
import { PeerManager } from './webrtc/peerManager';
|
import { PeerManager } from './webrtc/peerManager';
|
||||||
|
|
||||||
const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels';
|
const EFFECT_LEVELS_STORAGE_KEY = 'chatGridEffectLevels';
|
||||||
@@ -128,77 +138,9 @@ type AudioLayerState = {
|
|||||||
|
|
||||||
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/Anchorage',
|
|
||||||
'America/Argentina/Buenos_Aires',
|
|
||||||
'America/Chicago',
|
|
||||||
'America/Detroit',
|
|
||||||
'America/Halifax',
|
|
||||||
'America/Indiana/Indianapolis',
|
|
||||||
'America/Kentucky/Louisville',
|
|
||||||
'America/Los_Angeles',
|
|
||||||
'America/St_Johns',
|
|
||||||
'Asia/Bangkok',
|
|
||||||
'Asia/Dhaka',
|
|
||||||
'Asia/Dubai',
|
|
||||||
'Asia/Hong_Kong',
|
|
||||||
'Asia/Kabul',
|
|
||||||
'Asia/Karachi',
|
|
||||||
'Asia/Kathmandu',
|
|
||||||
'Asia/Kolkata',
|
|
||||||
'Asia/Seoul',
|
|
||||||
'Asia/Singapore',
|
|
||||||
'Asia/Tehran',
|
|
||||||
'Asia/Tokyo',
|
|
||||||
'Asia/Yangon',
|
|
||||||
'Atlantic/Azores',
|
|
||||||
'Atlantic/South_Georgia',
|
|
||||||
'Australia/Brisbane',
|
|
||||||
'Australia/Darwin',
|
|
||||||
'Australia/Eucla',
|
|
||||||
'Australia/Lord_Howe',
|
|
||||||
'Europe/Berlin',
|
|
||||||
'Europe/Helsinki',
|
|
||||||
'Europe/London',
|
|
||||||
'Europe/Moscow',
|
|
||||||
'Pacific/Apia',
|
|
||||||
'Pacific/Auckland',
|
|
||||||
'Pacific/Chatham',
|
|
||||||
'Pacific/Honolulu',
|
|
||||||
'Pacific/Kiritimati',
|
|
||||||
'Pacific/Noumea',
|
|
||||||
'Pacific/Pago_Pago',
|
|
||||||
'UTC',
|
|
||||||
] 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[] = ['clock', 'dice', 'radio_station', 'wheel'];
|
|
||||||
const ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
|
||||||
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 },
|
|
||||||
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 },
|
|
||||||
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 },
|
|
||||||
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
|
|
||||||
};
|
|
||||||
const EDITABLE_ITEM_PROPERTY_KEYS = new Set([
|
|
||||||
'title',
|
|
||||||
'streamUrl',
|
|
||||||
'enabled',
|
|
||||||
'channel',
|
|
||||||
'volume',
|
|
||||||
'effect',
|
|
||||||
'effectValue',
|
|
||||||
'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],
|
|
||||||
};
|
|
||||||
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 {
|
||||||
const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`;
|
const normalizedBase = APP_BASE_URL.endsWith('/') ? APP_BASE_URL : `${APP_BASE_URL}/`;
|
||||||
@@ -574,20 +516,10 @@ function getPeerNamesAtPosition(x: number, y: number): string[] {
|
|||||||
.map((peer) => peer.nickname);
|
.map((peer) => peer.nickname);
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemTypeLabel(type: ItemType): string {
|
|
||||||
if (type === 'radio_station') return 'radio';
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
function itemLabel(item: WorldItem): string {
|
function itemLabel(item: WorldItem): string {
|
||||||
return `${item.title} (${itemTypeLabel(item.type)})`;
|
return `${item.title} (${itemTypeLabel(item.type)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemPropertyLabel(key: string): string {
|
|
||||||
if (key === 'use24Hour') return 'use 24 hour format';
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openHelpViewer(): void {
|
function openHelpViewer(): void {
|
||||||
if (helpViewerLines.length === 0) {
|
if (helpViewerLines.length === 0) {
|
||||||
updateStatus('Help unavailable.');
|
updateStatus('Help unavailable.');
|
||||||
@@ -623,61 +555,6 @@ function beginItemSelection(context: 'pickup' | 'delete' | 'edit' | 'use' | 'ins
|
|||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEditableItemPropertyKeys(item: WorldItem): string[] {
|
|
||||||
const keys = ['title'];
|
|
||||||
if (item.type === 'radio_station') {
|
|
||||||
keys.push('streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue');
|
|
||||||
} else if (item.type === 'dice') {
|
|
||||||
keys.push('sides', 'number');
|
|
||||||
} else if (item.type === 'wheel') {
|
|
||||||
keys.push('spaces');
|
|
||||||
} else if (item.type === 'clock') {
|
|
||||||
keys.push('timeZone', 'use24Hour');
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInspectItemPropertyKeys(item: WorldItem): string[] {
|
|
||||||
const editableKeys = getEditableItemPropertyKeys(item);
|
|
||||||
const seen = new Set(editableKeys);
|
|
||||||
const allKeys: string[] = [...editableKeys];
|
|
||||||
|
|
||||||
const baseKeys = [
|
|
||||||
'type',
|
|
||||||
'x',
|
|
||||||
'y',
|
|
||||||
'carrierId',
|
|
||||||
'version',
|
|
||||||
'createdBy',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'capabilities',
|
|
||||||
'useSound',
|
|
||||||
'emitSound',
|
|
||||||
];
|
|
||||||
for (const key of baseKeys) {
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
allKeys.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paramKeys = Object.keys(item.params).sort((a, b) => a.localeCompare(b));
|
|
||||||
for (const key of paramKeys) {
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
allKeys.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
|
|
||||||
for (const key of globalKeys) {
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
allKeys.push(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return allKeys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function beginItemProperties(item: WorldItem, showAll = false): void {
|
function beginItemProperties(item: WorldItem, showAll = false): void {
|
||||||
state.selectedItemId = item.id;
|
state.selectedItemId = item.id;
|
||||||
state.mode = 'itemProperties';
|
state.mode = 'itemProperties';
|
||||||
@@ -701,7 +578,7 @@ function useItem(item: WorldItem): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
function openItemPropertyOptionSelect(item: WorldItem, key: string): void {
|
||||||
const options = OPTION_ITEM_PROPERTY_VALUES[key];
|
const options = getItemPropertyOptionValues(key);
|
||||||
if (!options || options.length === 0) {
|
if (!options || options.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1999,7 +1876,7 @@ function handleItemPropertiesModeInput(code: string, key: string): void {
|
|||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (OPTION_ITEM_PROPERTY_VALUES[key]) {
|
if (getItemPropertyOptionValues(key)) {
|
||||||
openItemPropertyOptionSelect(item, key);
|
openItemPropertyOptionSelect(item, key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@
|
|||||||
|
|
||||||
- Persisted state stores only instance data.
|
- Persisted state stores only instance data.
|
||||||
- Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`.
|
- Global/type-level properties are loaded from server registry in `server/app/item_catalog.py`.
|
||||||
|
- Per-type use/update validation and message behavior are handled in `server/app/item_type_handlers.py`.
|
||||||
|
- Client-side add/edit metadata is handled in `client/src/items/itemRegistry.ts`.
|
||||||
|
|
||||||
## Type Params
|
## Type Params
|
||||||
|
|
||||||
|
|||||||
@@ -95,3 +95,28 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
### Validation
|
### Validation
|
||||||
- `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
|
- `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
|
||||||
- `use24Hour`: boolean or on/off style input
|
- `use24Hour`: boolean or on/off style input
|
||||||
|
|
||||||
|
## Adding A New Item Type (Registry V1)
|
||||||
|
|
||||||
|
Item types are currently code-registered on both server and client so new types are additive instead of editing one large branch.
|
||||||
|
|
||||||
|
1. Server catalog: add global defaults in `server/app/item_catalog.py` (`ITEM_DEFINITIONS`).
|
||||||
|
2. Server handlers: add `validate_update` + `use` logic in `server/app/item_type_handlers.py` and register it in `ITEM_TYPE_HANDLERS`.
|
||||||
|
3. Server models: extend `ItemType` literals in `server/app/models.py` and any packet enums that list item types.
|
||||||
|
4. Client registry: add type metadata in `client/src/items/itemRegistry.ts` (`ITEM_TYPE_SEQUENCE`, editable properties, and global property hints).
|
||||||
|
5. Client protocol types: update item-type unions in `client/src/network/protocol.ts` and `client/src/state/gameState.ts`.
|
||||||
|
6. Tests: add or update server tests under `server/tests/` for use/update validation and cooldown behavior.
|
||||||
|
|
||||||
|
### Example Shape
|
||||||
|
|
||||||
|
A minimal new item type usually needs:
|
||||||
|
|
||||||
|
- Catalog defaults:
|
||||||
|
- `default_title`
|
||||||
|
- `default_params`
|
||||||
|
- `use_sound` / `emit_sound`
|
||||||
|
- `use_cooldown_ms`
|
||||||
|
- Handler behavior:
|
||||||
|
- validate params on update
|
||||||
|
- build self/others use messages
|
||||||
|
- optionally return delayed result text
|
||||||
|
|||||||
237
server/app/item_type_handlers.py
Normal file
237
server/app/item_type_handlers.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""Per-item-type use/update handlers for modular item behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import random
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .item_catalog import CLOCK_DEFAULT_TIME_ZONE, CLOCK_TIME_ZONE_OPTIONS, ItemType
|
||||||
|
from .models import WorldItem
|
||||||
|
|
||||||
|
RADIO_EFFECT_IDS = {"reverb", "echo", "flanger", "high_pass", "low_pass", "off"}
|
||||||
|
RADIO_CHANNEL_IDS = {"stereo", "mono", "left", "right"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ItemUseResult:
|
||||||
|
"""Result payload for a successful item use action."""
|
||||||
|
|
||||||
|
self_message: str
|
||||||
|
others_message: str
|
||||||
|
updated_params: dict | None = None
|
||||||
|
delayed_self_message: str | None = None
|
||||||
|
delayed_others_message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_enabled(value: object) -> bool:
|
||||||
|
"""Parse radio enabled-like values with permissive defaults."""
|
||||||
|
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return bool(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.strip().lower() in {"on", "true", "1", "yes"}
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_clock_use_24_hour(value: object) -> bool | None:
|
||||||
|
"""Parse bool-like clock format values (`on/off`, `true/false`, etc.)."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_radio_update(item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize `radio_station` params."""
|
||||||
|
|
||||||
|
stream_url = str(next_params.get("streamUrl", "")).strip()
|
||||||
|
previous_stream_url = str(item.params.get("streamUrl", "")).strip()
|
||||||
|
next_params["streamUrl"] = stream_url
|
||||||
|
enabled_value = next_params.get("enabled", True)
|
||||||
|
if isinstance(enabled_value, bool):
|
||||||
|
enabled = enabled_value
|
||||||
|
elif isinstance(enabled_value, (int, float)):
|
||||||
|
enabled = bool(enabled_value)
|
||||||
|
elif isinstance(enabled_value, str):
|
||||||
|
token = enabled_value.strip().lower()
|
||||||
|
if token in {"on", "true", "1", "yes"}:
|
||||||
|
enabled = True
|
||||||
|
elif token in {"off", "false", "0", "no"}:
|
||||||
|
enabled = False
|
||||||
|
else:
|
||||||
|
raise ValueError("enabled must be true/false or on/off.")
|
||||||
|
else:
|
||||||
|
raise ValueError("enabled must be true/false or on/off.")
|
||||||
|
if stream_url and stream_url != previous_stream_url:
|
||||||
|
enabled = True
|
||||||
|
if not stream_url:
|
||||||
|
enabled = False
|
||||||
|
next_params["enabled"] = enabled
|
||||||
|
|
||||||
|
try:
|
||||||
|
volume = int(next_params.get("volume", 50))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("volume must be a number.") from exc
|
||||||
|
if not (0 <= volume <= 100):
|
||||||
|
raise ValueError("volume must be between 0 and 100.")
|
||||||
|
next_params["volume"] = volume
|
||||||
|
|
||||||
|
effect = str(next_params.get("effect", "off")).strip().lower()
|
||||||
|
if effect not in RADIO_EFFECT_IDS:
|
||||||
|
raise ValueError("effect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
|
||||||
|
next_params["effect"] = effect
|
||||||
|
|
||||||
|
channel = str(next_params.get("channel", "stereo")).strip().lower()
|
||||||
|
if channel not in RADIO_CHANNEL_IDS:
|
||||||
|
raise ValueError("channel must be one of stereo, mono, left, right.")
|
||||||
|
next_params["channel"] = channel
|
||||||
|
|
||||||
|
try:
|
||||||
|
effect_value = float(next_params.get("effectValue", 50))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("effectValue must be a number.") from exc
|
||||||
|
if not (0 <= effect_value <= 100):
|
||||||
|
raise ValueError("effectValue must be between 0 and 100.")
|
||||||
|
next_params["effectValue"] = round(effect_value, 1)
|
||||||
|
return next_params
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_dice_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize `dice` params."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
sides = int(next_params.get("sides", 6))
|
||||||
|
number = int(next_params.get("number", 2))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Dice values must be numbers.") from exc
|
||||||
|
if not (1 <= sides <= 100 and 1 <= number <= 100):
|
||||||
|
raise ValueError("Dice sides and number must be between 1 and 100.")
|
||||||
|
next_params["sides"] = sides
|
||||||
|
next_params["number"] = number
|
||||||
|
return next_params
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_wheel_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize `wheel` params."""
|
||||||
|
|
||||||
|
spaces_raw = next_params.get("spaces", "")
|
||||||
|
if not isinstance(spaces_raw, str):
|
||||||
|
raise ValueError("spaces must be a comma-delimited string.")
|
||||||
|
spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()]
|
||||||
|
if not spaces:
|
||||||
|
raise ValueError("spaces must include at least one value, separated by commas.")
|
||||||
|
if len(spaces) > 100:
|
||||||
|
raise ValueError("spaces supports up to 100 values.")
|
||||||
|
if any(len(token) > 80 for token in spaces):
|
||||||
|
raise ValueError("each space must be 80 chars or less.")
|
||||||
|
next_params["spaces"] = ", ".join(spaces)
|
||||||
|
return next_params
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_clock_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize `clock` params."""
|
||||||
|
|
||||||
|
time_zone = str(next_params.get("timeZone", CLOCK_DEFAULT_TIME_ZONE)).strip()
|
||||||
|
if time_zone not in CLOCK_TIME_ZONE_OPTIONS:
|
||||||
|
raise ValueError(f"timeZone must be one of {', '.join(CLOCK_TIME_ZONE_OPTIONS)}.")
|
||||||
|
use_24_hour = _parse_clock_use_24_hour(next_params.get("use24Hour"))
|
||||||
|
if use_24_hour is None:
|
||||||
|
raise ValueError("use24Hour must be on/off.")
|
||||||
|
next_params["timeZone"] = time_zone
|
||||||
|
next_params["use24Hour"] = use_24_hour
|
||||||
|
return next_params
|
||||||
|
|
||||||
|
|
||||||
|
def _use_radio(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Compute `radio_station` use result and next params."""
|
||||||
|
|
||||||
|
currently_enabled = _parse_enabled(item.params.get("enabled", True))
|
||||||
|
next_enabled = not currently_enabled
|
||||||
|
state_text = "on" if next_enabled else "off"
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You turn {state_text} {item.title}.",
|
||||||
|
others_message=f"{nickname} turns {state_text} {item.title}.",
|
||||||
|
updated_params={**item.params, "enabled": next_enabled},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_dice(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Compute `dice` use result."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
sides = max(1, min(100, int(item.params.get("sides", 6))))
|
||||||
|
number = max(1, min(100, int(item.params.get("number", 2))))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sides = 6
|
||||||
|
number = 2
|
||||||
|
rolls = [random.randint(1, sides) for _ in range(number)]
|
||||||
|
total = sum(rolls)
|
||||||
|
rolls_text = ", ".join(str(value) for value in rolls)
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You rolled {item.title}: {rolls_text} (total {total}).",
|
||||||
|
others_message=f"{nickname} rolled {item.title}: {rolls_text} (total {total}).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_wheel(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Compute `wheel` use result and delayed result text."""
|
||||||
|
|
||||||
|
spaces_raw = item.params.get("spaces", "")
|
||||||
|
if isinstance(spaces_raw, str):
|
||||||
|
spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()]
|
||||||
|
elif isinstance(spaces_raw, list):
|
||||||
|
spaces = [str(token).strip() for token in spaces_raw if str(token).strip()]
|
||||||
|
else:
|
||||||
|
spaces = []
|
||||||
|
if not spaces:
|
||||||
|
raise ValueError("wheel spaces must contain at least one comma-delimited value.")
|
||||||
|
landed = str(random.choice(spaces))
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You spin {item.title}.",
|
||||||
|
others_message=f"{nickname} spins {item.title}.",
|
||||||
|
delayed_self_message=landed,
|
||||||
|
delayed_others_message=landed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _use_clock(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Compute `clock` use result."""
|
||||||
|
|
||||||
|
display_time = clock_formatter(item.params)
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"{item.title} says {display_time}.",
|
||||||
|
others_message=f"{nickname} checks {item.title}. {item.title} says {display_time}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ItemTypeHandler:
|
||||||
|
"""Validation and use handlers for one item type."""
|
||||||
|
|
||||||
|
validate_update: Callable[[WorldItem, dict], dict]
|
||||||
|
use: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
|
||||||
|
|
||||||
|
|
||||||
|
ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = {
|
||||||
|
"radio_station": ItemTypeHandler(validate_update=_validate_radio_update, use=_use_radio),
|
||||||
|
"dice": ItemTypeHandler(validate_update=_validate_dice_update, use=_use_dice),
|
||||||
|
"wheel": ItemTypeHandler(validate_update=_validate_wheel_update, use=_use_wheel),
|
||||||
|
"clock": ItemTypeHandler(validate_update=_validate_clock_update, use=_use_clock),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_item_type_handler(item_type: ItemType) -> ItemTypeHandler:
|
||||||
|
"""Resolve item-type handler from registry."""
|
||||||
|
|
||||||
|
return ITEM_TYPE_HANDLERS[item_type]
|
||||||
@@ -7,7 +7,6 @@ import asyncio
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import random
|
|
||||||
import ssl
|
import ssl
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -20,6 +19,7 @@ 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 CLOCK_DEFAULT_TIME_ZONE, CLOCK_TIME_ZONE_OPTIONS, get_item_use_cooldown_ms
|
from .item_catalog import CLOCK_DEFAULT_TIME_ZONE, CLOCK_TIME_ZONE_OPTIONS, get_item_use_cooldown_ms
|
||||||
|
from .item_type_handlers import get_item_type_handler
|
||||||
from .item_service import ItemService
|
from .item_service import ItemService
|
||||||
from .models import (
|
from .models import (
|
||||||
BroadcastChatMessagePacket,
|
BroadcastChatMessagePacket,
|
||||||
@@ -52,8 +52,6 @@ from .models import (
|
|||||||
LOGGER = logging.getLogger("chgrid.server")
|
LOGGER = logging.getLogger("chgrid.server")
|
||||||
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
||||||
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
||||||
RADIO_EFFECT_IDS = {"reverb", "echo", "flanger", "high_pass", "low_pass", "off"}
|
|
||||||
RADIO_CHANNEL_IDS = {"stereo", "mono", "left", "right"}
|
|
||||||
|
|
||||||
|
|
||||||
class SignalingServer:
|
class SignalingServer:
|
||||||
@@ -520,9 +518,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", "clock"}:
|
handler = get_item_type_handler(item.type)
|
||||||
await self._send_item_result(client, False, "use", "This item cannot be used yet.", item.id)
|
|
||||||
return
|
|
||||||
now_ms = self.item_service.now_ms()
|
now_ms = self.item_service.now_ms()
|
||||||
cooldown_ms = get_item_use_cooldown_ms(item.type)
|
cooldown_ms = get_item_use_cooldown_ms(item.type)
|
||||||
last_use_ms = self.item_last_use_ms.get(item.id)
|
last_use_ms = self.item_last_use_ms.get(item.id)
|
||||||
@@ -537,68 +533,21 @@ class SignalingServer:
|
|||||||
item.id,
|
item.id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
delayed_wheel_self_result: str | None = None
|
try:
|
||||||
delayed_wheel_others_result: str | None = None
|
use_result = handler.use(item, client.nickname, self._format_clock_display_time)
|
||||||
if item.type == "radio_station":
|
except ValueError as exc:
|
||||||
enabled_value = item.params.get("enabled", True)
|
await self._send_item_result(client, False, "use", str(exc), item.id)
|
||||||
if isinstance(enabled_value, bool):
|
return
|
||||||
currently_enabled = enabled_value
|
|
||||||
elif isinstance(enabled_value, (int, float)):
|
if use_result.updated_params is not None:
|
||||||
currently_enabled = bool(enabled_value)
|
item.params = use_result.updated_params
|
||||||
elif isinstance(enabled_value, str):
|
|
||||||
currently_enabled = enabled_value.strip().lower() in {"on", "true", "1", "yes"}
|
|
||||||
else:
|
|
||||||
currently_enabled = True
|
|
||||||
next_enabled = not currently_enabled
|
|
||||||
item.params = {**item.params, "enabled": next_enabled}
|
|
||||||
item.updatedAt = now_ms
|
item.updatedAt = now_ms
|
||||||
self.item_service.save_state()
|
self.item_service.save_state()
|
||||||
await self._broadcast_item(item)
|
await self._broadcast_item(item)
|
||||||
state_text = "on" if next_enabled else "off"
|
|
||||||
others_message = f"{client.nickname} turns {state_text} {item.title}."
|
|
||||||
self_message = f"You turn {state_text} {item.title}."
|
|
||||||
elif item.type == "dice":
|
|
||||||
try:
|
|
||||||
sides = max(1, min(100, int(item.params.get("sides", 6))))
|
|
||||||
number = max(1, min(100, int(item.params.get("number", 2))))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
sides = 6
|
|
||||||
number = 2
|
|
||||||
rolls = [random.randint(1, sides) for _ in range(number)]
|
|
||||||
total = sum(rolls)
|
|
||||||
others_message = (
|
|
||||||
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})."
|
|
||||||
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()]
|
|
||||||
elif isinstance(spaces_raw, list):
|
|
||||||
spaces = [str(token).strip() for token in spaces_raw if str(token).strip()]
|
|
||||||
else:
|
|
||||||
spaces = []
|
|
||||||
if not spaces:
|
|
||||||
await self._send_item_result(
|
|
||||||
client,
|
|
||||||
False,
|
|
||||||
"use",
|
|
||||||
"wheel spaces must contain at least one comma-delimited value.",
|
|
||||||
item.id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
landed = random.choice(spaces)
|
|
||||||
others_message = f"{client.nickname} spins {item.title}."
|
|
||||||
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}."
|
|
||||||
self.item_last_use_ms[item.id] = now_ms
|
self.item_last_use_ms[item.id] = now_ms
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
BroadcastChatMessagePacket(type="chat_message", message=others_message, system=True),
|
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
||||||
exclude=client.websocket,
|
exclude=client.websocket,
|
||||||
)
|
)
|
||||||
if item.useSound:
|
if item.useSound:
|
||||||
@@ -611,13 +560,13 @@ class SignalingServer:
|
|||||||
y=item.y,
|
y=item.y,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await self._send_item_result(client, True, "use", self_message, item.id)
|
await self._send_item_result(client, True, "use", use_result.self_message, item.id)
|
||||||
if delayed_wheel_self_result is not None and delayed_wheel_others_result is not None:
|
if use_result.delayed_self_message is not None and use_result.delayed_others_message is not None:
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self._broadcast_wheel_result_after_delay(
|
self._broadcast_wheel_result_after_delay(
|
||||||
client=client,
|
client=client,
|
||||||
self_message=delayed_wheel_self_result,
|
self_message=use_result.delayed_self_message,
|
||||||
others_message=delayed_wheel_others_result,
|
others_message=use_result.delayed_others_message,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -641,145 +590,12 @@ class SignalingServer:
|
|||||||
item.title = title[:80]
|
item.title = title[:80]
|
||||||
if packet.params:
|
if packet.params:
|
||||||
next_params = {**item.params, **packet.params}
|
next_params = {**item.params, **packet.params}
|
||||||
if item.type == "dice":
|
handler = get_item_type_handler(item.type)
|
||||||
try:
|
try:
|
||||||
sides = int(next_params.get("sides", 6))
|
next_params = handler.validate_update(item, next_params)
|
||||||
number = int(next_params.get("number", 2))
|
except ValueError as exc:
|
||||||
except (TypeError, ValueError):
|
await self._send_item_result(client, False, "update", str(exc), item.id)
|
||||||
await self._send_item_result(client, False, "update", "Dice values must be numbers.", item.id)
|
return
|
||||||
return
|
|
||||||
if not (1 <= sides <= 100 and 1 <= number <= 100):
|
|
||||||
await self._send_item_result(
|
|
||||||
client, False, "update", "Dice sides and number must be between 1 and 100.", item.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
next_params["sides"] = sides
|
|
||||||
next_params["number"] = number
|
|
||||||
if item.type == "wheel":
|
|
||||||
spaces_raw = next_params.get("spaces", "")
|
|
||||||
if not isinstance(spaces_raw, str):
|
|
||||||
await self._send_item_result(
|
|
||||||
client, False, "update", "spaces must be a comma-delimited string.", item.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()]
|
|
||||||
if not spaces:
|
|
||||||
await self._send_item_result(
|
|
||||||
client,
|
|
||||||
False,
|
|
||||||
"update",
|
|
||||||
"spaces must include at least one value, separated by commas.",
|
|
||||||
item.id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if len(spaces) > 100:
|
|
||||||
await self._send_item_result(client, False, "update", "spaces supports up to 100 values.", item.id)
|
|
||||||
return
|
|
||||||
if any(len(token) > 80 for token in spaces):
|
|
||||||
await self._send_item_result(client, False, "update", "each space must be 80 chars or less.", item.id)
|
|
||||||
return
|
|
||||||
next_params["spaces"] = ", ".join(spaces)
|
|
||||||
if item.type == "radio_station":
|
|
||||||
stream_url = str(next_params.get("streamUrl", "")).strip()
|
|
||||||
previous_stream_url = str(item.params.get("streamUrl", "")).strip()
|
|
||||||
next_params["streamUrl"] = stream_url
|
|
||||||
enabled_value = next_params.get("enabled", True)
|
|
||||||
if isinstance(enabled_value, bool):
|
|
||||||
enabled = enabled_value
|
|
||||||
elif isinstance(enabled_value, (int, float)):
|
|
||||||
enabled = bool(enabled_value)
|
|
||||||
elif isinstance(enabled_value, str):
|
|
||||||
token = enabled_value.strip().lower()
|
|
||||||
if token in {"on", "true", "1", "yes"}:
|
|
||||||
enabled = True
|
|
||||||
elif token in {"off", "false", "0", "no"}:
|
|
||||||
enabled = False
|
|
||||||
else:
|
|
||||||
await self._send_item_result(
|
|
||||||
client, False, "update", "enabled must be true/false or on/off.", item.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
await self._send_item_result(
|
|
||||||
client, False, "update", "enabled must be true/false or on/off.", item.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if stream_url and stream_url != previous_stream_url:
|
|
||||||
enabled = True
|
|
||||||
if not stream_url:
|
|
||||||
enabled = False
|
|
||||||
next_params["enabled"] = enabled
|
|
||||||
|
|
||||||
try:
|
|
||||||
volume = int(next_params.get("volume", 50))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
await self._send_item_result(client, False, "update", "volume must be a number.", item.id)
|
|
||||||
return
|
|
||||||
if not (0 <= volume <= 100):
|
|
||||||
await self._send_item_result(
|
|
||||||
client, False, "update", "volume must be between 0 and 100.", item.id
|
|
||||||
)
|
|
||||||
return
|
|
||||||
next_params["volume"] = volume
|
|
||||||
|
|
||||||
effect = str(next_params.get("effect", "off")).strip().lower()
|
|
||||||
if effect not in RADIO_EFFECT_IDS:
|
|
||||||
await self._send_item_result(
|
|
||||||
client,
|
|
||||||
False,
|
|
||||||
"update",
|
|
||||||
"effect must be one of reverb, echo, flanger, high_pass, low_pass, off.",
|
|
||||||
item.id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
next_params["effect"] = effect
|
|
||||||
|
|
||||||
channel = str(next_params.get("channel", "stereo")).strip().lower()
|
|
||||||
if channel not in RADIO_CHANNEL_IDS:
|
|
||||||
await self._send_item_result(
|
|
||||||
client,
|
|
||||||
False,
|
|
||||||
"update",
|
|
||||||
"channel must be one of stereo, mono, left, right.",
|
|
||||||
item.id,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
next_params["channel"] = channel
|
|
||||||
|
|
||||||
try:
|
|
||||||
effect_value = float(next_params.get("effectValue", 50))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
await self._send_item_result(client, False, "update", "effectValue must be a number.", item.id)
|
|
||||||
return
|
|
||||||
if not (0 <= effect_value <= 100):
|
|
||||||
await self._send_item_result(
|
|
||||||
client, False, "update", "effectValue must be between 0 and 100.", item.id
|
|
||||||
)
|
|
||||||
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.params = next_params
|
||||||
item.updatedAt = self.item_service.now_ms()
|
item.updatedAt = self.item_service.now_ms()
|
||||||
item.version += 1
|
item.version += 1
|
||||||
|
|||||||
Reference in New Issue
Block a user