Split server item logic into per-item modules
This commit is contained in:
@@ -5,65 +5,27 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from .items import clock, dice, radio, wheel
|
||||||
|
|
||||||
ItemType = Literal["radio_station", "dice", "wheel", "clock"]
|
ItemType = Literal["radio_station", "dice", "wheel", "clock"]
|
||||||
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = ("clock", "dice", "radio_station", "wheel")
|
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = ("clock", "dice", "radio_station", "wheel")
|
||||||
ITEM_TYPE_LABELS: dict[ItemType, str] = {
|
ITEM_TYPE_LABELS: dict[ItemType, str] = {
|
||||||
"radio_station": "radio",
|
"radio_station": radio.LABEL,
|
||||||
"dice": "dice",
|
"dice": dice.LABEL,
|
||||||
"wheel": "wheel",
|
"wheel": wheel.LABEL,
|
||||||
"clock": "clock",
|
"clock": clock.LABEL,
|
||||||
}
|
}
|
||||||
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, ...]] = {
|
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
||||||
"radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue", "facing", "emitRange"),
|
"radio_station": radio.EDITABLE_PROPERTIES,
|
||||||
"dice": ("title", "sides", "number"),
|
"dice": dice.EDITABLE_PROPERTIES,
|
||||||
"wheel": ("title", "spaces"),
|
"wheel": wheel.EDITABLE_PROPERTIES,
|
||||||
"clock": ("title", "timeZone", "use24Hour"),
|
"clock": clock.EDITABLE_PROPERTIES,
|
||||||
}
|
}
|
||||||
CLOCK_DEFAULT_TIME_ZONE = "America/Detroit"
|
|
||||||
CLOCK_TIME_ZONE_OPTIONS: tuple[str, ...] = (
|
CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE
|
||||||
"America/Anchorage",
|
CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS
|
||||||
"America/Argentina/Buenos_Aires",
|
RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS
|
||||||
"America/Chicago",
|
RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS
|
||||||
"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",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -80,47 +42,71 @@ class ItemDefinition:
|
|||||||
directional: bool = False
|
directional: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _build_definition(
|
||||||
|
*,
|
||||||
|
default_title: str,
|
||||||
|
capabilities: tuple[str, ...],
|
||||||
|
use_sound: str | None,
|
||||||
|
emit_sound: str | None,
|
||||||
|
default_params: dict,
|
||||||
|
use_cooldown_ms: int,
|
||||||
|
emit_range: int,
|
||||||
|
directional: bool,
|
||||||
|
) -> ItemDefinition:
|
||||||
|
"""Build one immutable catalog definition from an item module."""
|
||||||
|
|
||||||
|
return ItemDefinition(
|
||||||
|
default_title=default_title,
|
||||||
|
capabilities=capabilities,
|
||||||
|
use_sound=use_sound,
|
||||||
|
emit_sound=emit_sound,
|
||||||
|
default_params=default_params,
|
||||||
|
use_cooldown_ms=use_cooldown_ms,
|
||||||
|
emit_range=emit_range,
|
||||||
|
directional=directional,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
||||||
"radio_station": ItemDefinition(
|
"radio_station": _build_definition(
|
||||||
default_title="radio",
|
default_title=radio.DEFAULT_TITLE,
|
||||||
capabilities=("editable", "carryable", "deletable", "usable"),
|
capabilities=radio.CAPABILITIES,
|
||||||
use_sound=None,
|
use_sound=radio.USE_SOUND,
|
||||||
emit_sound=None,
|
emit_sound=radio.EMIT_SOUND,
|
||||||
default_params={
|
default_params=radio.DEFAULT_PARAMS,
|
||||||
"streamUrl": "",
|
use_cooldown_ms=radio.USE_COOLDOWN_MS,
|
||||||
"enabled": True,
|
emit_range=radio.EMIT_RANGE,
|
||||||
"channel": "stereo",
|
directional=radio.DIRECTIONAL,
|
||||||
"volume": 50,
|
|
||||||
"effect": "off",
|
|
||||||
"effectValue": 50,
|
|
||||||
"facing": 0,
|
|
||||||
"emitRange": 20,
|
|
||||||
},
|
|
||||||
emit_range=20,
|
|
||||||
directional=True,
|
|
||||||
),
|
),
|
||||||
"dice": ItemDefinition(
|
"dice": _build_definition(
|
||||||
default_title="Dice",
|
default_title=dice.DEFAULT_TITLE,
|
||||||
capabilities=("editable", "carryable", "deletable", "usable"),
|
capabilities=dice.CAPABILITIES,
|
||||||
use_sound="sounds/roll.ogg",
|
use_sound=dice.USE_SOUND,
|
||||||
emit_sound=None,
|
emit_sound=dice.EMIT_SOUND,
|
||||||
default_params={"sides": 6, "number": 2},
|
default_params=dice.DEFAULT_PARAMS,
|
||||||
|
use_cooldown_ms=dice.USE_COOLDOWN_MS,
|
||||||
|
emit_range=dice.EMIT_RANGE,
|
||||||
|
directional=dice.DIRECTIONAL,
|
||||||
),
|
),
|
||||||
"wheel": ItemDefinition(
|
"wheel": _build_definition(
|
||||||
default_title="wheel",
|
default_title=wheel.DEFAULT_TITLE,
|
||||||
capabilities=("editable", "carryable", "deletable", "usable"),
|
capabilities=wheel.CAPABILITIES,
|
||||||
use_sound="sounds/spin.ogg",
|
use_sound=wheel.USE_SOUND,
|
||||||
emit_sound=None,
|
emit_sound=wheel.EMIT_SOUND,
|
||||||
default_params={"spaces": "yes, no"},
|
default_params=wheel.DEFAULT_PARAMS,
|
||||||
use_cooldown_ms=4000,
|
use_cooldown_ms=wheel.USE_COOLDOWN_MS,
|
||||||
|
emit_range=wheel.EMIT_RANGE,
|
||||||
|
directional=wheel.DIRECTIONAL,
|
||||||
),
|
),
|
||||||
"clock": ItemDefinition(
|
"clock": _build_definition(
|
||||||
default_title="clock",
|
default_title=clock.DEFAULT_TITLE,
|
||||||
capabilities=("editable", "carryable", "deletable", "usable"),
|
capabilities=clock.CAPABILITIES,
|
||||||
use_sound=None,
|
use_sound=clock.USE_SOUND,
|
||||||
emit_sound="sounds/clock.ogg",
|
emit_sound=clock.EMIT_SOUND,
|
||||||
default_params={"timeZone": CLOCK_DEFAULT_TIME_ZONE, "use24Hour": False},
|
default_params=clock.DEFAULT_PARAMS,
|
||||||
emit_range=10,
|
use_cooldown_ms=clock.USE_COOLDOWN_MS,
|
||||||
|
emit_range=clock.EMIT_RANGE,
|
||||||
|
directional=clock.DIRECTIONAL,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,10 +117,10 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
|
||||||
"radio_station": "Can play stations from the Internet. Tune multiple to the same station and they will sync up.",
|
"radio_station": radio.TOOLTIP,
|
||||||
"dice": "Great for drinking games or boredom.",
|
"dice": dice.TOOLTIP,
|
||||||
"wheel": "Spin to win fabulous prizes.",
|
"wheel": wheel.TOOLTIP,
|
||||||
"clock": "It tells the time. What did you think it did?",
|
"clock": clock.TOOLTIP,
|
||||||
}
|
}
|
||||||
|
|
||||||
GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
@@ -146,62 +132,10 @@ GLOBAL_ITEM_PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = {
|
ITEM_TYPE_PROPERTY_METADATA: dict[ItemType, dict[str, dict[str, object]]] = {
|
||||||
"radio_station": {
|
"radio_station": {**GLOBAL_ITEM_PROPERTY_METADATA, **radio.PROPERTY_METADATA},
|
||||||
**GLOBAL_ITEM_PROPERTY_METADATA,
|
"dice": {**GLOBAL_ITEM_PROPERTY_METADATA, **dice.PROPERTY_METADATA},
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
"wheel": {**GLOBAL_ITEM_PROPERTY_METADATA, **wheel.PROPERTY_METADATA},
|
||||||
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio."},
|
"clock": {**GLOBAL_ITEM_PROPERTY_METADATA, **clock.PROPERTY_METADATA},
|
||||||
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
|
|
||||||
"channel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
|
||||||
"volume": {
|
|
||||||
"valueType": "number",
|
|
||||||
"tooltip": "Playback volume percent for this radio.",
|
|
||||||
"range": {"min": 0, "max": 100, "step": 1},
|
|
||||||
},
|
|
||||||
"effect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
|
||||||
"effectValue": {
|
|
||||||
"valueType": "number",
|
|
||||||
"tooltip": "Amount for the selected effect.",
|
|
||||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
|
||||||
},
|
|
||||||
"facing": {
|
|
||||||
"valueType": "number",
|
|
||||||
"tooltip": "Facing direction in degrees used for directional emit.",
|
|
||||||
"range": {"min": 0, "max": 360, "step": 0.1},
|
|
||||||
},
|
|
||||||
"emitRange": {
|
|
||||||
"valueType": "number",
|
|
||||||
"tooltip": "Maximum distance in squares for this radio's emitted audio.",
|
|
||||||
"range": {"min": 5, "max": 20, "step": 1},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"dice": {
|
|
||||||
**GLOBAL_ITEM_PROPERTY_METADATA,
|
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
|
||||||
"sides": {
|
|
||||||
"valueType": "number",
|
|
||||||
"tooltip": "Number of sides on each die.",
|
|
||||||
"range": {"min": 1, "max": 100, "step": 1},
|
|
||||||
},
|
|
||||||
"number": {
|
|
||||||
"valueType": "number",
|
|
||||||
"tooltip": "How many dice to roll per use.",
|
|
||||||
"range": {"min": 1, "max": 100, "step": 1},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"wheel": {
|
|
||||||
**GLOBAL_ITEM_PROPERTY_METADATA,
|
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
|
||||||
"spaces": {
|
|
||||||
"valueType": "text",
|
|
||||||
"tooltip": "Comma-delimited list of wheel spaces. Example: yes, no, maybe.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"clock": {
|
|
||||||
**GLOBAL_ITEM_PROPERTY_METADATA,
|
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
|
||||||
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
|
|
||||||
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -232,3 +166,4 @@ def get_item_global_properties(item_type: ItemType) -> dict[str, str | int | boo
|
|||||||
"emitRange": definition.emit_range if isinstance(definition.emit_range, int) and definition.emit_range > 0 else 15,
|
"emitRange": definition.emit_range if isinstance(definition.emit_range, int) and definition.emit_range > 0 else 15,
|
||||||
"directional": bool(definition.directional),
|
"directional": bool(definition.directional),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,249 +1,16 @@
|
|||||||
"""Per-item-type use/update handlers for modular item behavior."""
|
"""Per-item-type handler registry."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from .item_catalog import ItemType
|
||||||
import random
|
from .items import clock, dice, radio, wheel
|
||||||
from typing import Callable
|
from .item_types import ItemTypeHandler
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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"] = 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
|
|
||||||
|
|
||||||
|
|
||||||
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] = {
|
ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = {
|
||||||
"radio_station": ItemTypeHandler(validate_update=_validate_radio_update, use=_use_radio),
|
"radio_station": ItemTypeHandler(validate_update=radio.validate_update, use=radio.use_item),
|
||||||
"dice": ItemTypeHandler(validate_update=_validate_dice_update, use=_use_dice),
|
"dice": ItemTypeHandler(validate_update=dice.validate_update, use=dice.use_item),
|
||||||
"wheel": ItemTypeHandler(validate_update=_validate_wheel_update, use=_use_wheel),
|
"wheel": ItemTypeHandler(validate_update=wheel.validate_update, use=wheel.use_item),
|
||||||
"clock": ItemTypeHandler(validate_update=_validate_clock_update, use=_use_clock),
|
"clock": ItemTypeHandler(validate_update=clock.validate_update, use=clock.use_item),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -251,3 +18,4 @@ def get_item_type_handler(item_type: ItemType) -> ItemTypeHandler:
|
|||||||
"""Resolve item-type handler from registry."""
|
"""Resolve item-type handler from registry."""
|
||||||
|
|
||||||
return ITEM_TYPE_HANDLERS[item_type]
|
return ITEM_TYPE_HANDLERS[item_type]
|
||||||
|
|
||||||
|
|||||||
27
server/app/item_types.py
Normal file
27
server/app/item_types.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Shared item behavior types used by per-item modules and registry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from .models import WorldItem
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@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]
|
||||||
2
server/app/items/__init__.py
Normal file
2
server/app/items/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Per-item modules containing item-specific schema and behavior."""
|
||||||
|
|
||||||
95
server/app/items/clock.py
Normal file
95
server/app/items/clock.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""Clock 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_or_none
|
||||||
|
|
||||||
|
LABEL = "clock"
|
||||||
|
TOOLTIP = "It tells the time. What did you think it did?"
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "timeZone", "use24Hour")
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND: str | None = None
|
||||||
|
EMIT_SOUND = "sounds/clock.ogg"
|
||||||
|
USE_COOLDOWN_MS = 1000
|
||||||
|
EMIT_RANGE = 10
|
||||||
|
DIRECTIONAL = False
|
||||||
|
DEFAULT_TITLE = "clock"
|
||||||
|
DEFAULT_TIME_ZONE = "America/Detroit"
|
||||||
|
TIME_ZONE_OPTIONS: tuple[str, ...] = (
|
||||||
|
"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",
|
||||||
|
)
|
||||||
|
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False}
|
||||||
|
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
||||||
|
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
|
||||||
|
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize clock params."""
|
||||||
|
|
||||||
|
time_zone = str(next_params.get("timeZone", DEFAULT_TIME_ZONE)).strip()
|
||||||
|
if time_zone not in TIME_ZONE_OPTIONS:
|
||||||
|
raise ValueError(f"timeZone must be one of {', '.join(TIME_ZONE_OPTIONS)}.")
|
||||||
|
use_24_hour = parse_bool_like_or_none(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_item(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Read current clock time based on item configuration."""
|
||||||
|
|
||||||
|
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}.",
|
||||||
|
)
|
||||||
|
|
||||||
69
server/app/items/dice.py
Normal file
69
server/app/items/dice.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dice item schema metadata and behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..item_types import ItemUseResult
|
||||||
|
from ..models import WorldItem
|
||||||
|
|
||||||
|
LABEL = "dice"
|
||||||
|
TOOLTIP = "Great for drinking games or boredom."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "sides", "number")
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND = "sounds/roll.ogg"
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
USE_COOLDOWN_MS = 1000
|
||||||
|
EMIT_RANGE = 15
|
||||||
|
DIRECTIONAL = False
|
||||||
|
DEFAULT_TITLE = "Dice"
|
||||||
|
DEFAULT_PARAMS: dict = {"sides": 6, "number": 2}
|
||||||
|
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
||||||
|
"sides": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Number of sides on each die.",
|
||||||
|
"range": {"min": 1, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
|
"number": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "How many dice to roll per use.",
|
||||||
|
"range": {"min": 1, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_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 use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Roll dice and report 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}).",
|
||||||
|
)
|
||||||
|
|
||||||
43
server/app/items/helpers.py
Normal file
43
server/app/items/helpers.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Shared helper utilities for per-item behavior modules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool_like(value: object, *, default: bool = True) -> bool:
|
||||||
|
"""Parse permissive bool-like values used by item params."""
|
||||||
|
|
||||||
|
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 default
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool_like_or_none(value: object) -> bool | None:
|
||||||
|
"""Parse permissive bool-like values, returning None when invalid."""
|
||||||
|
|
||||||
|
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 toggle_bool_param(params: dict, key: str, *, default: bool = True) -> bool:
|
||||||
|
"""Toggle a bool-like item param key and return the next value."""
|
||||||
|
|
||||||
|
current = parse_bool_like(params.get(key), default=default)
|
||||||
|
return not current
|
||||||
|
|
||||||
156
server/app/items/radio.py
Normal file
156
server/app/items/radio.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
"""Radio 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 toggle_bool_param
|
||||||
|
|
||||||
|
LABEL = "radio"
|
||||||
|
TOOLTIP = "Can play stations from the Internet. Tune multiple to the same station and they will sync up."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
||||||
|
"title",
|
||||||
|
"streamUrl",
|
||||||
|
"enabled",
|
||||||
|
"channel",
|
||||||
|
"volume",
|
||||||
|
"effect",
|
||||||
|
"effectValue",
|
||||||
|
"facing",
|
||||||
|
"emitRange",
|
||||||
|
)
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND: str | None = None
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
USE_COOLDOWN_MS = 1000
|
||||||
|
EMIT_RANGE = 20
|
||||||
|
DIRECTIONAL = True
|
||||||
|
DEFAULT_TITLE = "radio"
|
||||||
|
DEFAULT_PARAMS: dict = {
|
||||||
|
"streamUrl": "",
|
||||||
|
"enabled": True,
|
||||||
|
"channel": "stereo",
|
||||||
|
"volume": 50,
|
||||||
|
"effect": "off",
|
||||||
|
"effectValue": 50,
|
||||||
|
"facing": 0,
|
||||||
|
"emitRange": 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right")
|
||||||
|
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."},
|
||||||
|
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio."},
|
||||||
|
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
|
||||||
|
"channel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
||||||
|
"volume": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Playback volume percent for this radio.",
|
||||||
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
|
"effect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
||||||
|
"effectValue": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Amount for the selected effect.",
|
||||||
|
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||||
|
},
|
||||||
|
"facing": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Facing direction in degrees used for directional emit.",
|
||||||
|
"range": {"min": 0, "max": 360, "step": 0.1},
|
||||||
|
},
|
||||||
|
"emitRange": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Maximum distance in squares for this radio's emitted audio.",
|
||||||
|
"range": {"min": 5, "max": 20, "step": 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize radio 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 EFFECT_OPTIONS:
|
||||||
|
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 CHANNEL_OPTIONS:
|
||||||
|
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)
|
||||||
|
|
||||||
|
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"] = 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
|
||||||
|
|
||||||
|
|
||||||
|
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Toggle radio on/off when used."""
|
||||||
|
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
|
||||||
68
server/app/items/wheel.py
Normal file
68
server/app/items/wheel.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""Wheel item schema metadata and behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..item_types import ItemUseResult
|
||||||
|
from ..models import WorldItem
|
||||||
|
|
||||||
|
LABEL = "wheel"
|
||||||
|
TOOLTIP = "Spin to win fabulous prizes."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "spaces")
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND = "sounds/spin.ogg"
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
USE_COOLDOWN_MS = 4000
|
||||||
|
EMIT_RANGE = 15
|
||||||
|
DIRECTIONAL = False
|
||||||
|
DEFAULT_TITLE = "wheel"
|
||||||
|
DEFAULT_PARAMS: dict = {"spaces": "yes, no"}
|
||||||
|
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
||||||
|
"spaces": {
|
||||||
|
"valueType": "text",
|
||||||
|
"tooltip": "Comma-delimited list of wheel spaces. Example: yes, no, maybe.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_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 use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Spin wheel and produce delayed landed value."""
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
Reference in New Issue
Block a user