Files
chat_grid/server/app/items/widget.py
2026-02-22 22:21:27 -05:00

197 lines
7.6 KiB
Python

"""Widget item schema metadata and behavior."""
from __future__ import annotations
from typing import Callable
from ..item_types import ItemUseResult
from ..models import WorldItem
from .helpers import parse_bool_like, toggle_bool_param
LABEL = "widget"
TOOLTIP = "A basic item. Make it a beacon or whatever you want."
EDITABLE_PROPERTIES: tuple[str, ...] = (
"title",
"enabled",
"directional",
"facing",
"emitRange",
"emitVolume",
"emitSoundSpeed",
"emitSoundTempo",
"emitEffect",
"emitEffectValue",
"useSound",
"emitSound",
)
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
USE_SOUND: str | None = None
EMIT_SOUND: str | None = None
USE_COOLDOWN_MS = 1000
EMIT_RANGE = 15
DIRECTIONAL = False
DEFAULT_TITLE = "widget"
DEFAULT_PARAMS: dict = {
"enabled": True,
"directional": False,
"facing": 0,
"emitRange": 15,
"emitVolume": 100,
"emitSoundSpeed": 50,
"emitSoundTempo": 50,
"emitEffect": "off",
"emitEffectValue": 50,
"useSound": "",
"emitSound": "",
}
EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"enabled": {"valueType": "boolean", "tooltip": "Turns this widget on or off."},
"directional": {"valueType": "boolean", "tooltip": "If on, emitted sound favors the facing direction."},
"facing": {
"valueType": "number",
"tooltip": "Facing direction in degrees used when directional is on.",
"range": {"min": 0, "max": 360, "step": 1},
},
"emitRange": {
"valueType": "number",
"tooltip": "Maximum distance in squares for emitted sound.",
"range": {"min": 1, "max": 20, "step": 1},
},
"emitVolume": {
"valueType": "number",
"tooltip": "Emitted sound volume percent.",
"range": {"min": 0, "max": 100, "step": 1},
},
"emitSoundSpeed": {
"valueType": "number",
"tooltip": "Playback speed/pitch percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
"range": {"min": 0, "max": 100, "step": 0.1},
},
"emitSoundTempo": {
"valueType": "number",
"tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
"range": {"min": 0, "max": 100, "step": 0.1},
},
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."},
"emitEffectValue": {
"valueType": "number",
"tooltip": "Amount for emit effect.",
"range": {"min": 0, "max": 100, "step": 0.1},
},
"useSound": {
"valueType": "sound",
"tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL.",
"maxLength": 2048,
},
"emitSound": {
"valueType": "sound",
"tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL.",
"maxLength": 2048,
},
}
def _normalize_sound_value(raw: object) -> str:
"""Normalize sound value to empty/URL/or sounds-relative path."""
token = str(raw or "").strip()
if not token:
return ""
lowered = token.lower()
if lowered in {"none", "off"}:
return ""
if lowered.startswith(("http://", "https://", "data:", "blob:")):
return token
if token.startswith("/sounds/"):
return token[1:]
if token.startswith("sounds/"):
return token
if "/" not in token:
return f"sounds/{token}"
return token
def validate_update(item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize widget params."""
enabled = parse_bool_like(next_params.get("enabled", item.params.get("enabled", True)), default=True)
directional = parse_bool_like(next_params.get("directional", item.params.get("directional", False)), default=False)
next_params["enabled"] = enabled
next_params["directional"] = directional
try:
facing = float(next_params.get("facing", item.params.get("facing", 0)))
except (TypeError, ValueError) as exc:
raise ValueError("facing must be a number between 0 and 360.") from exc
if not (0 <= facing <= 360):
raise ValueError("facing must be between 0 and 360.")
next_params["facing"] = int(round(facing))
try:
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 15)))
except (TypeError, ValueError) as exc:
raise ValueError("emitRange must be an integer between 1 and 20.") from exc
if not (1 <= emit_range <= 20):
raise ValueError("emitRange must be between 1 and 20.")
next_params["emitRange"] = emit_range
try:
emit_volume = int(next_params.get("emitVolume", item.params.get("emitVolume", 100)))
except (TypeError, ValueError) as exc:
raise ValueError("emitVolume must be an integer between 0 and 100.") from exc
if not (0 <= emit_volume <= 100):
raise ValueError("emitVolume must be between 0 and 100.")
next_params["emitVolume"] = emit_volume
try:
emit_speed = float(next_params.get("emitSoundSpeed", item.params.get("emitSoundSpeed", 50)))
except (TypeError, ValueError) as exc:
raise ValueError("emitSoundSpeed must be a number between 0 and 100.") from exc
if not (0 <= emit_speed <= 100):
raise ValueError("emitSoundSpeed must be between 0 and 100.")
next_params["emitSoundSpeed"] = round(emit_speed, 1)
try:
emit_tempo = float(next_params.get("emitSoundTempo", item.params.get("emitSoundTempo", 50)))
except (TypeError, ValueError) as exc:
raise ValueError("emitSoundTempo must be a number between 0 and 100.") from exc
if not (0 <= emit_tempo <= 100):
raise ValueError("emitSoundTempo must be between 0 and 100.")
next_params["emitSoundTempo"] = round(emit_tempo, 1)
emit_effect = str(next_params.get("emitEffect", item.params.get("emitEffect", "off"))).strip().lower()
if emit_effect not in EFFECT_OPTIONS:
raise ValueError("emitEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
next_params["emitEffect"] = emit_effect
try:
emit_effect_value = float(next_params.get("emitEffectValue", item.params.get("emitEffectValue", 50)))
except (TypeError, ValueError) as exc:
raise ValueError("emitEffectValue must be a number.") from exc
if not (0 <= emit_effect_value <= 100):
raise ValueError("emitEffectValue must be between 0 and 100.")
next_params["emitEffectValue"] = round(emit_effect_value, 1)
next_params["useSound"] = _normalize_sound_value(next_params.get("useSound", item.params.get("useSound", "")))
next_params["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", "")))
if len(next_params["useSound"]) > 2048:
raise ValueError("useSound must be 2048 characters or less.")
if len(next_params["emitSound"]) > 2048:
raise ValueError("emitSound must be 2048 characters or less.")
return next_params
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Toggle enabled state for widget."""
next_enabled = toggle_bool_param(item.params, "enabled", default=True)
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},
)