Split server item logic into per-item modules

This commit is contained in:
Jage9
2026-02-21 21:58:35 -05:00
parent a3dca78397
commit 6fec20e9cd
9 changed files with 554 additions and 391 deletions

View File

@@ -0,0 +1,2 @@
"""Per-item modules containing item-specific schema and behavior."""

95
server/app/items/clock.py Normal file
View 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
View 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}).",
)

View 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
View 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
View 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,
)