diff --git a/client/public/version.js b/client/public/version.js index 98bc303..71ed924 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 R119"; +window.CHGRID_WEB_VERSION = "2026.02.21 R120"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 3de4ab3..9c7a7c3 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -48,7 +48,7 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [ const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel']; const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record = { - radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing'], + radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'], dice: ['title', 'sides', 'number'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], @@ -131,6 +131,7 @@ export function itemTypeLabel(type: ItemType): string { export function itemPropertyLabel(key: string): string { if (key === 'use24Hour') return 'use 24 hour format'; + if (key === 'emitRange') return 'emit range'; return key; } diff --git a/client/src/main.ts b/client/src/main.ts index 1a0a69b..5c663b3 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -525,7 +525,9 @@ function itemLabel(item: WorldItem): string { function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } { const global = getItemTypeGlobalProperties(item.type); - const rawRange = Number(global.emitRange); + const rawParamRange = Number(item.params.emitRange); + const rawGlobalRange = Number(global.emitRange); + const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange; const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15; const directional = global.directional === true; const rawFacing = Number(item.params.facing ?? 0); @@ -713,6 +715,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string { if (!Number.isFinite(parsed)) return '0'; return String(Math.round(normalizeDegrees(parsed) * 10) / 10); } + if (key === 'emitRange') { + const parsed = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type)?.emitRange ?? 15); + if (!Number.isFinite(parsed)) return '15'; + return String(Math.round(parsed)); + } const globalValue = getItemTypeGlobalProperties(item.type)?.[key]; if (globalValue !== undefined) return String(globalValue); return String(item.params[key] ?? ''); @@ -2008,6 +2015,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo return; } signaling.send({ type: 'item_update', itemId, params: { facing: Math.round(parsed * 10) / 10 } }); + } else if (propertyKey === 'emitRange') { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 5 || parsed > 20) { + updateStatus('emit range must be an integer between 5 and 20.'); + audio.sfxUiCancel(); + return; + } + signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed } }); } else if (propertyKey === 'spaces') { const spaces = value .split(',') diff --git a/docs/item-schema.md b/docs/item-schema.md index d0952b5..e604d43 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -25,7 +25,8 @@ - `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1. - `capabilities`, `useSound`, 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. -- `emitRange`: global spatial range per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`), not per-instance editable. +- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`). + - `radio_station` can override this per instance via `params.emitRange` (`5..20`). - `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`), not per-instance editable. ## Persisted Item State (`server/runtime/items.json`) @@ -64,7 +65,8 @@ "volume": 50, "effect": "off", "effectValue": 50, - "facing": 0 + "facing": 0, + "emitRange": 20 } ``` @@ -76,6 +78,7 @@ - `effect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `effectValue`: number, range `0-100`, precision `0.1`. - `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`). +- `emitRange`: integer, range `5-20`, default `20`. ### `dice` diff --git a/docs/item-types.md b/docs/item-types.md index a920c26..298b23d 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -26,6 +26,7 @@ This is behavior-focused documentation for item types and their defaults. - `effect="off"` - `effectValue=50` - `facing=0` + - `emitRange=20` - Global: - `useSound=none` - `emitSound=none` @@ -42,6 +43,7 @@ This is behavior-focused documentation for item types and their defaults. - `effect`: `reverb | echo | flanger | high_pass | low_pass | off` - `effectValue`: number `0..100` with `0.1` precision - `facing`: number `0..360` with `0.1` precision +- `emitRange`: integer `5..20` ## `dice` diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index b933bfb..a991ffb 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -16,7 +16,7 @@ ITEM_TYPE_LABELS: dict[ItemType, str] = { RADIO_EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off") RADIO_CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right") ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = { - "radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue", "facing"), + "radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue", "facing", "emitRange"), "dice": ("title", "sides", "number"), "wheel": ("title", "spaces"), "clock": ("title", "timeZone", "use24Hour"), @@ -86,7 +86,16 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { capabilities=("editable", "carryable", "deletable", "usable"), use_sound=None, emit_sound=None, - default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50, "facing": 0}, + default_params={ + "streamUrl": "", + "enabled": True, + "channel": "stereo", + "volume": 50, + "effect": "off", + "effectValue": 50, + "facing": 0, + "emitRange": 20, + }, emit_range=20, directional=True, ), diff --git a/server/app/item_type_handlers.py b/server/app/item_type_handlers.py index 3e02817..06ad75f 100644 --- a/server/app/item_type_handlers.py +++ b/server/app/item_type_handlers.py @@ -112,6 +112,14 @@ def _validate_radio_update(item: WorldItem, next_params: dict) -> dict: if not (0 <= facing <= 360): raise ValueError("facing must be between 0 and 360.") next_params["facing"] = round(facing, 1) + + try: + emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 20))) + except (TypeError, ValueError) as exc: + raise ValueError("emitRange must be an integer between 5 and 20.") from exc + if not (5 <= emit_range <= 20): + raise ValueError("emitRange must be between 5 and 20.") + next_params["emitRange"] = emit_range return next_params diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 56dbf9e..1d4b6a7 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -132,6 +132,20 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) - assert send_payloads[-1].ok is False assert "facing must be between 0 and 360" in send_payloads[-1].message.lower() + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 12}}), + ) + assert send_payloads[-1].ok is True + assert item.params.get("emitRange") == 12 + + await server._handle_message( + client, + json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 4}}), + ) + assert send_payloads[-1].ok is False + assert "emitrange must be between 5 and 20" in send_payloads[-1].message.lower() + @pytest.mark.asyncio async def test_clock_use_reports_time_without_use_sound_packet(monkeypatch: pytest.MonkeyPatch) -> None: