refactor: split item type packages into definition/validator/actions

This commit is contained in:
Jage9
2026-02-24 03:08:30 -05:00
parent 7903bab131
commit 3718449156
39 changed files with 1216 additions and 862 deletions

View File

@@ -1 +1,3 @@
"""Item type plugin package."""
"""Item type package exposing plugin module surface."""
from .module import * # noqa: F401,F403

View File

@@ -0,0 +1,18 @@
"""Clock item use actions."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
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}.",
)

View File

@@ -0,0 +1,65 @@
"""Clock item static metadata and defaults."""
from __future__ import annotations
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}
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
}

View File

@@ -1,95 +1,41 @@
"""Clock item schema metadata and behavior."""
"""Clock item plugin module surface."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import keep_only_known_params, 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",
from .actions import use_item
from .definition import (
CAPABILITIES,
DEFAULT_PARAMS,
DEFAULT_TIME_ZONE,
DEFAULT_TITLE,
DIRECTIONAL,
EDITABLE_PROPERTIES,
EMIT_RANGE,
EMIT_SOUND,
LABEL,
PROPERTY_METADATA,
TIME_ZONE_OPTIONS,
TOOLTIP,
USE_COOLDOWN_MS,
USE_SOUND,
)
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False}
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
from .validator import validate_update
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"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 keep_only_known_params(next_params, PARAM_KEYS)
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}.",
)
__all__ = [
"LABEL",
"TOOLTIP",
"EDITABLE_PROPERTIES",
"CAPABILITIES",
"USE_SOUND",
"EMIT_SOUND",
"USE_COOLDOWN_MS",
"EMIT_RANGE",
"DIRECTIONAL",
"DEFAULT_TITLE",
"DEFAULT_TIME_ZONE",
"TIME_ZONE_OPTIONS",
"DEFAULT_PARAMS",
"PROPERTY_METADATA",
"validate_update",
"use_item",
]

View File

@@ -0,0 +1,21 @@
"""Clock item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params, parse_bool_like_or_none
from .definition import DEFAULT_TIME_ZONE, PARAM_KEYS, TIME_ZONE_OPTIONS
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 keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -1 +1,3 @@
"""Item type plugin package."""
"""Item type package exposing plugin module surface."""
from .module import * # noqa: F401,F403

View File

@@ -0,0 +1,32 @@
"""Dice item use actions."""
from __future__ import annotations
import random
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
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)
if number == 1:
return ItemUseResult(
self_message=f"You rolled {item.title}: {rolls_text}.",
others_message=f"{nickname} rolled {item.title}: {rolls_text}.",
)
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,30 @@
"""Dice item static metadata and defaults."""
from __future__ import annotations
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}
PARAM_KEYS: tuple[str, ...] = ("sides", "number")
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"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},
},
}

View File

@@ -1,75 +1,37 @@
"""Dice item schema metadata and behavior."""
"""Dice item plugin module surface."""
from __future__ import annotations
import random
from typing import Callable
from .actions import use_item
from .definition import (
CAPABILITIES,
DEFAULT_PARAMS,
DEFAULT_TITLE,
DIRECTIONAL,
EDITABLE_PROPERTIES,
EMIT_RANGE,
EMIT_SOUND,
LABEL,
PROPERTY_METADATA,
TOOLTIP,
USE_COOLDOWN_MS,
USE_SOUND,
)
from .validator import validate_update
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import keep_only_known_params
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}
PARAM_KEYS: tuple[str, ...] = ("sides", "number")
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"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 keep_only_known_params(next_params, PARAM_KEYS)
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)
if number == 1:
return ItemUseResult(
self_message=f"You rolled {item.title}: {rolls_text}.",
others_message=f"{nickname} rolled {item.title}: {rolls_text}.",
)
return ItemUseResult(
self_message=f"You rolled {item.title}: {rolls_text} (total {total}).",
others_message=f"{nickname} rolled {item.title}: {rolls_text} (total {total}).",
)
__all__ = [
"LABEL",
"TOOLTIP",
"EDITABLE_PROPERTIES",
"CAPABILITIES",
"USE_SOUND",
"EMIT_SOUND",
"USE_COOLDOWN_MS",
"EMIT_RANGE",
"DIRECTIONAL",
"DEFAULT_TITLE",
"DEFAULT_PARAMS",
"PROPERTY_METADATA",
"validate_update",
"use_item",
]

View File

@@ -0,0 +1,22 @@
"""Dice item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params
from .definition import PARAM_KEYS
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 keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -1 +1,3 @@
"""Item type plugin package."""
"""Item type package exposing plugin module surface."""
from .module import * # noqa: F401,F403

View File

@@ -0,0 +1,17 @@
"""Piano item use actions."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Enter piano play mode for the user who used the item."""
return ItemUseResult(
self_message=f"You begin playing {item.title}.",
others_message=f"{nickname} begins playing {item.title}.",
)

View File

@@ -0,0 +1,99 @@
"""Piano item static metadata and defaults."""
from __future__ import annotations
LABEL = "piano"
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
EDITABLE_PROPERTIES: tuple[str, ...] = (
"title",
"instrument",
"voiceMode",
"octave",
"attack",
"decay",
"release",
"brightness",
"emitRange",
)
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 = "piano"
DEFAULT_PARAMS: dict = {
"instrument": "piano",
"voiceMode": "poly",
"octave": 0,
"attack": 15,
"decay": 45,
"release": 35,
"brightness": 55,
"emitRange": 15,
"songId": "unterlandersheimweh",
}
PARAM_KEYS: tuple[str, ...] = ("instrument", "voiceMode", "octave", "attack", "decay", "release", "brightness", "emitRange", "songId")
INSTRUMENT_OPTIONS: tuple[str, ...] = (
"piano",
"electric_piano",
"guitar",
"organ",
"bass",
"violin",
"synth_lead",
"brass",
"nintendo",
"drum_kit",
)
VOICE_MODE_OPTIONS: tuple[str, ...] = ("poly", "mono")
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] = {
"piano": (15, 45, 35, 55, "poly", 0),
"electric_piano": (12, 40, 30, 62, "poly", 0),
"guitar": (8, 35, 25, 50, "poly", 0),
"organ": (25, 70, 45, 48, "poly", 0),
"bass": (2, 24, 18, 34, "mono", -1),
"violin": (22, 75, 55, 58, "mono", 0),
"synth_lead": (6, 30, 22, 72, "poly", 0),
"brass": (10, 45, 30, 60, "mono", 0),
"nintendo": (1, 24, 15, 85, "poly", 0),
"drum_kit": (1, 22, 12, 68, "poly", 0),
}
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."},
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."},
"octave": {
"valueType": "number",
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",
"range": {"min": -2, "max": 2, "step": 1},
},
"attack": {
"valueType": "number",
"tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.",
"range": {"min": 0, "max": 100, "step": 1},
},
"decay": {
"valueType": "number",
"tooltip": "How long notes ring out after the initial hit.",
"range": {"min": 0, "max": 100, "step": 1},
},
"release": {
"valueType": "number",
"tooltip": "How long notes continue after key release.",
"range": {"min": 0, "max": 100, "step": 1},
},
"brightness": {
"valueType": "number",
"tooltip": "Tone brightness; higher values sound brighter.",
"range": {"min": 0, "max": 100, "step": 1},
},
"emitRange": {
"valueType": "number",
"tooltip": "Maximum distance in squares where this piano can be heard.",
"range": {"min": 5, "max": 20, "step": 1},
},
}

View File

@@ -1,193 +1,41 @@
"""Piano item schema metadata and behavior."""
"""Piano item plugin module surface."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import keep_only_known_params
LABEL = "piano"
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
EDITABLE_PROPERTIES: tuple[str, ...] = (
"title",
"instrument",
"voiceMode",
"octave",
"attack",
"decay",
"release",
"brightness",
"emitRange",
from .actions import use_item
from .definition import (
CAPABILITIES,
DEFAULT_PARAMS,
DEFAULT_TITLE,
DIRECTIONAL,
EDITABLE_PROPERTIES,
EMIT_RANGE,
EMIT_SOUND,
INSTRUMENT_OPTIONS,
LABEL,
PROPERTY_METADATA,
TOOLTIP,
USE_COOLDOWN_MS,
USE_SOUND,
VOICE_MODE_OPTIONS,
)
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 = "piano"
DEFAULT_PARAMS: dict = {
"instrument": "piano",
"voiceMode": "poly",
"octave": 0,
"attack": 15,
"decay": 45,
"release": 35,
"brightness": 55,
"emitRange": 15,
"songId": "unterlandersheimweh",
}
PARAM_KEYS: tuple[str, ...] = ("instrument", "voiceMode", "octave", "attack", "decay", "release", "brightness", "emitRange", "songId")
from .validator import validate_update
INSTRUMENT_OPTIONS: tuple[str, ...] = (
"piano",
"electric_piano",
"guitar",
"organ",
"bass",
"violin",
"synth_lead",
"brass",
"nintendo",
"drum_kit",
)
VOICE_MODE_OPTIONS: tuple[str, ...] = ("poly", "mono")
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] = {
"piano": (15, 45, 35, 55, "poly", 0),
"electric_piano": (12, 40, 30, 62, "poly", 0),
"guitar": (8, 35, 25, 50, "poly", 0),
"organ": (25, 70, 45, 48, "poly", 0),
"bass": (2, 24, 18, 34, "mono", -1),
"violin": (22, 75, 55, 58, "mono", 0),
"synth_lead": (6, 30, 22, 72, "poly", 0),
"brass": (10, 45, 30, 60, "mono", 0),
"nintendo": (1, 24, 15, 85, "poly", 0),
"drum_kit": (1, 22, 12, 68, "poly", 0),
}
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."},
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."},
"octave": {
"valueType": "number",
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",
"range": {"min": -2, "max": 2, "step": 1},
},
"attack": {
"valueType": "number",
"tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.",
"range": {"min": 0, "max": 100, "step": 1},
},
"decay": {
"valueType": "number",
"tooltip": "How long notes ring out after the initial hit.",
"range": {"min": 0, "max": 100, "step": 1},
},
"release": {
"valueType": "number",
"tooltip": "How long notes continue after key release.",
"range": {"min": 0, "max": 100, "step": 1},
},
"brightness": {
"valueType": "number",
"tooltip": "Tone brightness; higher values sound brighter.",
"range": {"min": 0, "max": 100, "step": 1},
},
"emitRange": {
"valueType": "number",
"tooltip": "Maximum distance in squares where this piano can be heard.",
"range": {"min": 5, "max": 20, "step": 1},
},
}
def validate_update(_item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize piano params."""
# Song references are server-managed and not directly editable from client updates.
preserved_song_id = _item.params.get("songId")
next_params.pop("songId", None)
instrument = str(next_params.get("instrument", "piano")).strip().lower()
if instrument not in INSTRUMENT_OPTIONS:
raise ValueError(f"instrument must be one of: {', '.join(INSTRUMENT_OPTIONS)}.")
previous_instrument = str(_item.params.get("instrument", "piano")).strip().lower()
next_params["instrument"] = instrument
voice_mode = str(next_params.get("voiceMode", _item.params.get("voiceMode", "poly"))).strip().lower()
if voice_mode not in VOICE_MODE_OPTIONS:
raise ValueError("voiceMode must be one of: poly, mono.")
next_params["voiceMode"] = voice_mode
try:
octave = int(next_params.get("octave", _item.params.get("octave", 0)))
except (TypeError, ValueError) as exc:
raise ValueError("octave must be an integer between -2 and 2.") from exc
if not (-2 <= octave <= 2):
raise ValueError("octave must be between -2 and 2.")
next_params["octave"] = octave
try:
attack = int(next_params.get("attack", 15))
except (TypeError, ValueError) as exc:
raise ValueError("attack must be an integer between 0 and 100.") from exc
if not (0 <= attack <= 100):
raise ValueError("attack must be between 0 and 100.")
try:
decay = int(next_params.get("decay", 45))
except (TypeError, ValueError) as exc:
raise ValueError("decay must be an integer between 0 and 100.") from exc
if not (0 <= decay <= 100):
raise ValueError("decay must be between 0 and 100.")
try:
release = int(next_params.get("release", 35))
except (TypeError, ValueError) as exc:
raise ValueError("release must be an integer between 0 and 100.") from exc
if not (0 <= release <= 100):
raise ValueError("release must be between 0 and 100.")
try:
brightness = int(next_params.get("brightness", 55))
except (TypeError, ValueError) as exc:
raise ValueError("brightness must be an integer between 0 and 100.") from exc
if not (0 <= brightness <= 100):
raise ValueError("brightness must be between 0 and 100.")
# When instrument changes, reset envelope to instrument-appropriate defaults.
if instrument != previous_instrument:
attack, decay, release, brightness, voice_mode, octave = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(
instrument, (15, 45, 35, 55, "poly", 0)
)
next_params["voiceMode"] = voice_mode
next_params["octave"] = octave
next_params["attack"] = attack
next_params["decay"] = decay
next_params["release"] = release
next_params["brightness"] = brightness
try:
emit_range = int(next_params.get("emitRange", 15))
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
if isinstance(preserved_song_id, str) and preserved_song_id.strip():
next_params["songId"] = preserved_song_id.strip()
return keep_only_known_params(next_params, PARAM_KEYS)
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
"""Enter piano play mode for the user who used the item."""
return ItemUseResult(
self_message=f"You begin playing {item.title}.",
others_message=f"{nickname} begins playing {item.title}.",
)
__all__ = [
"LABEL",
"TOOLTIP",
"EDITABLE_PROPERTIES",
"CAPABILITIES",
"USE_SOUND",
"EMIT_SOUND",
"USE_COOLDOWN_MS",
"EMIT_RANGE",
"DIRECTIONAL",
"DEFAULT_TITLE",
"DEFAULT_PARAMS",
"PROPERTY_METADATA",
"INSTRUMENT_OPTIONS",
"VOICE_MODE_OPTIONS",
"validate_update",
"use_item",
]

View File

@@ -0,0 +1,86 @@
"""Piano item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params
from .definition import DEFAULT_ENVELOPE_BY_INSTRUMENT, INSTRUMENT_OPTIONS, PARAM_KEYS, VOICE_MODE_OPTIONS
def validate_update(item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize piano params."""
# Song references are server-managed and not directly editable from client updates.
preserved_song_id = item.params.get("songId")
next_params.pop("songId", None)
instrument = str(next_params.get("instrument", "piano")).strip().lower()
if instrument not in INSTRUMENT_OPTIONS:
raise ValueError(f"instrument must be one of: {', '.join(INSTRUMENT_OPTIONS)}.")
previous_instrument = str(item.params.get("instrument", "piano")).strip().lower()
next_params["instrument"] = instrument
voice_mode = str(next_params.get("voiceMode", item.params.get("voiceMode", "poly"))).strip().lower()
if voice_mode not in VOICE_MODE_OPTIONS:
raise ValueError("voiceMode must be one of: poly, mono.")
next_params["voiceMode"] = voice_mode
try:
octave = int(next_params.get("octave", item.params.get("octave", 0)))
except (TypeError, ValueError) as exc:
raise ValueError("octave must be an integer between -2 and 2.") from exc
if not (-2 <= octave <= 2):
raise ValueError("octave must be between -2 and 2.")
next_params["octave"] = octave
try:
attack = int(next_params.get("attack", 15))
except (TypeError, ValueError) as exc:
raise ValueError("attack must be an integer between 0 and 100.") from exc
if not (0 <= attack <= 100):
raise ValueError("attack must be between 0 and 100.")
try:
decay = int(next_params.get("decay", 45))
except (TypeError, ValueError) as exc:
raise ValueError("decay must be an integer between 0 and 100.") from exc
if not (0 <= decay <= 100):
raise ValueError("decay must be between 0 and 100.")
try:
release = int(next_params.get("release", 35))
except (TypeError, ValueError) as exc:
raise ValueError("release must be an integer between 0 and 100.") from exc
if not (0 <= release <= 100):
raise ValueError("release must be between 0 and 100.")
try:
brightness = int(next_params.get("brightness", 55))
except (TypeError, ValueError) as exc:
raise ValueError("brightness must be an integer between 0 and 100.") from exc
if not (0 <= brightness <= 100):
raise ValueError("brightness must be between 0 and 100.")
# When instrument changes, reset envelope to instrument-appropriate defaults.
if instrument != previous_instrument:
attack, decay, release, brightness, voice_mode, octave = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(
instrument, (15, 45, 35, 55, "poly", 0)
)
next_params["voiceMode"] = voice_mode
next_params["octave"] = octave
next_params["attack"] = attack
next_params["decay"] = decay
next_params["release"] = release
next_params["brightness"] = brightness
try:
emit_range = int(next_params.get("emitRange", 15))
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
if isinstance(preserved_song_id, str) and preserved_song_id.strip():
next_params["songId"] = preserved_song_id.strip()
return keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -1 +1,3 @@
"""Item type plugin package."""
"""Item type package exposing plugin module surface."""
from .module import * # noqa: F401,F403

View File

@@ -0,0 +1,21 @@
"""Radio item use actions."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import toggle_bool_param
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},
)

View File

@@ -0,0 +1,76 @@
"""Radio item static metadata and defaults."""
from __future__ import annotations
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",
"mediaVolume",
"mediaChannel",
"mediaEffect",
"mediaEffectValue",
"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,
"mediaVolume": 50,
"mediaChannel": "stereo",
"mediaEffect": "off",
"mediaEffectValue": 50,
"facing": 0,
"emitRange": 20,
}
PARAM_KEYS: tuple[str, ...] = (
"streamUrl",
"enabled",
"mediaVolume",
"mediaChannel",
"mediaEffect",
"mediaEffectValue",
"facing",
"emitRange",
)
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.", "maxLength": 80},
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio.", "maxLength": 2048},
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
"mediaVolume": {
"valueType": "number",
"tooltip": "Playback media volume percent for this radio.",
"range": {"min": 0, "max": 100, "step": 1},
},
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
"mediaEffectValue": {
"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": 1},
"visibleWhen": {"directional": True},
},
"emitRange": {
"valueType": "number",
"tooltip": "Maximum distance in squares for this radio's emitted audio.",
"range": {"min": 5, "max": 20, "step": 1},
},
}

View File

@@ -1,163 +1,41 @@
"""Radio item schema metadata and behavior."""
"""Radio item plugin module surface."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import keep_only_known_params, 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",
"mediaVolume",
"mediaChannel",
"mediaEffect",
"mediaEffectValue",
"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,
"mediaVolume": 50,
"mediaChannel": "stereo",
"mediaEffect": "off",
"mediaEffectValue": 50,
"facing": 0,
"emitRange": 20,
}
PARAM_KEYS: tuple[str, ...] = (
"streamUrl",
"enabled",
"mediaVolume",
"mediaChannel",
"mediaEffect",
"mediaEffectValue",
"facing",
"emitRange",
from .actions import use_item
from .definition import (
CAPABILITIES,
CHANNEL_OPTIONS,
DEFAULT_PARAMS,
DEFAULT_TITLE,
DIRECTIONAL,
EDITABLE_PROPERTIES,
EFFECT_OPTIONS,
EMIT_RANGE,
EMIT_SOUND,
LABEL,
PROPERTY_METADATA,
TOOLTIP,
USE_COOLDOWN_MS,
USE_SOUND,
)
from .validator import validate_update
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.", "maxLength": 80},
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio.", "maxLength": 2048},
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
"mediaVolume": {
"valueType": "number",
"tooltip": "Playback media volume percent for this radio.",
"range": {"min": 0, "max": 100, "step": 1},
},
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
"mediaEffectValue": {
"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": 1},
"visibleWhen": {"directional": True},
},
"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()
if len(stream_url) > 2048:
raise ValueError("streamUrl must be 2048 characters or less.")
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.")
next_params["enabled"] = enabled
try:
media_volume = int(next_params.get("mediaVolume", 50))
except (TypeError, ValueError) as exc:
raise ValueError("mediaVolume must be a number.") from exc
if not (0 <= media_volume <= 100):
raise ValueError("mediaVolume must be between 0 and 100.")
next_params["mediaVolume"] = media_volume
effect = str(next_params.get("mediaEffect", "off")).strip().lower()
if effect not in EFFECT_OPTIONS:
raise ValueError("mediaEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
next_params["mediaEffect"] = effect
channel = str(next_params.get("mediaChannel", "stereo")).strip().lower()
if channel not in CHANNEL_OPTIONS:
raise ValueError("mediaChannel must be one of stereo, mono, left, right.")
next_params["mediaChannel"] = channel
try:
effect_value = float(next_params.get("mediaEffectValue", 50))
except (TypeError, ValueError) as exc:
raise ValueError("mediaEffectValue must be a number.") from exc
if not (0 <= effect_value <= 100):
raise ValueError("mediaEffectValue must be between 0 and 100.")
next_params["mediaEffectValue"] = 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"] = int(round(facing))
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 keep_only_known_params(next_params, PARAM_KEYS)
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},
)
__all__ = [
"LABEL",
"TOOLTIP",
"EDITABLE_PROPERTIES",
"CAPABILITIES",
"USE_SOUND",
"EMIT_SOUND",
"USE_COOLDOWN_MS",
"EMIT_RANGE",
"DIRECTIONAL",
"DEFAULT_TITLE",
"DEFAULT_PARAMS",
"PROPERTY_METADATA",
"CHANNEL_OPTIONS",
"EFFECT_OPTIONS",
"validate_update",
"use_item",
]

View File

@@ -0,0 +1,76 @@
"""Radio item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params
from .definition import CHANNEL_OPTIONS, EFFECT_OPTIONS, PARAM_KEYS
def validate_update(item: WorldItem, next_params: dict) -> dict:
"""Validate and normalize radio params."""
stream_url = str(next_params.get("streamUrl", "")).strip()
if len(stream_url) > 2048:
raise ValueError("streamUrl must be 2048 characters or less.")
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.")
next_params["enabled"] = enabled
try:
media_volume = int(next_params.get("mediaVolume", 50))
except (TypeError, ValueError) as exc:
raise ValueError("mediaVolume must be a number.") from exc
if not (0 <= media_volume <= 100):
raise ValueError("mediaVolume must be between 0 and 100.")
next_params["mediaVolume"] = media_volume
effect = str(next_params.get("mediaEffect", "off")).strip().lower()
if effect not in EFFECT_OPTIONS:
raise ValueError("mediaEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
next_params["mediaEffect"] = effect
channel = str(next_params.get("mediaChannel", "stereo")).strip().lower()
if channel not in CHANNEL_OPTIONS:
raise ValueError("mediaChannel must be one of stereo, mono, left, right.")
next_params["mediaChannel"] = channel
try:
effect_value = float(next_params.get("mediaEffectValue", 50))
except (TypeError, ValueError) as exc:
raise ValueError("mediaEffectValue must be a number.") from exc
if not (0 <= effect_value <= 100):
raise ValueError("mediaEffectValue must be between 0 and 100.")
next_params["mediaEffectValue"] = 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"] = int(round(facing))
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 keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -1 +1,3 @@
"""Item type plugin package."""
"""Item type package exposing plugin module surface."""
from .module import * # noqa: F401,F403

View File

@@ -0,0 +1,30 @@
"""Wheel item use actions."""
from __future__ import annotations
import random
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
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,
)

View File

@@ -0,0 +1,25 @@
"""Wheel item static metadata and defaults."""
from __future__ import annotations
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"}
PARAM_KEYS: tuple[str, ...] = ("spaces",)
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"spaces": {
"valueType": "text",
"tooltip": "Comma-delimited list of wheel spaces. Example: yes, no, maybe.",
"maxLength": 4000,
},
}

View File

@@ -1,72 +1,37 @@
"""Wheel item schema metadata and behavior."""
"""Wheel item plugin module surface."""
from __future__ import annotations
import random
from typing import Callable
from .actions import use_item
from .definition import (
CAPABILITIES,
DEFAULT_PARAMS,
DEFAULT_TITLE,
DIRECTIONAL,
EDITABLE_PROPERTIES,
EMIT_RANGE,
EMIT_SOUND,
LABEL,
PROPERTY_METADATA,
TOOLTIP,
USE_COOLDOWN_MS,
USE_SOUND,
)
from .validator import validate_update
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import keep_only_known_params
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"}
PARAM_KEYS: tuple[str, ...] = ("spaces",)
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
"spaces": {
"valueType": "text",
"tooltip": "Comma-delimited list of wheel spaces. Example: yes, no, maybe.",
"maxLength": 4000,
},
}
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.")
if len(spaces_raw) > 4000:
raise ValueError("spaces must be 4000 characters or less.")
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 keep_only_known_params(next_params, PARAM_KEYS)
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,
)
__all__ = [
"LABEL",
"TOOLTIP",
"EDITABLE_PROPERTIES",
"CAPABILITIES",
"USE_SOUND",
"EMIT_SOUND",
"USE_COOLDOWN_MS",
"EMIT_RANGE",
"DIRECTIONAL",
"DEFAULT_TITLE",
"DEFAULT_PARAMS",
"PROPERTY_METADATA",
"validate_update",
"use_item",
]

View File

@@ -0,0 +1,26 @@
"""Wheel item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params
from .definition import PARAM_KEYS
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.")
if len(spaces_raw) > 4000:
raise ValueError("spaces must be 4000 characters or less.")
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 keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -1 +1,3 @@
"""Item type plugin package."""
"""Item type package exposing plugin module surface."""
from .module import * # noqa: F401,F403

View File

@@ -0,0 +1,21 @@
"""Widget item use actions."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import toggle_bool_param
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},
)

View File

@@ -0,0 +1,102 @@
"""Widget item static metadata and defaults."""
from __future__ import annotations
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": "",
}
PARAM_KEYS: tuple[str, ...] = (
"enabled",
"directional",
"facing",
"emitRange",
"emitVolume",
"emitSoundSpeed",
"emitSoundTempo",
"emitEffect",
"emitEffectValue",
"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},
"visibleWhen": {"directional": True},
},
"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,
},
}

View File

@@ -1,210 +1,39 @@
"""Widget item schema metadata and behavior."""
"""Widget item plugin module surface."""
from __future__ import annotations
from typing import Callable
from ....item_types import ItemUseResult
from ....models import WorldItem
from ...helpers import keep_only_known_params, 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",
from .actions import use_item
from .definition import (
CAPABILITIES,
DEFAULT_PARAMS,
DEFAULT_TITLE,
DIRECTIONAL,
EDITABLE_PROPERTIES,
EFFECT_OPTIONS,
EMIT_RANGE,
EMIT_SOUND,
LABEL,
PROPERTY_METADATA,
TOOLTIP,
USE_COOLDOWN_MS,
USE_SOUND,
)
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": "",
}
PARAM_KEYS: tuple[str, ...] = (
"enabled",
"directional",
"facing",
"emitRange",
"emitVolume",
"emitSoundSpeed",
"emitSoundTempo",
"emitEffect",
"emitEffectValue",
"useSound",
"emitSound",
)
EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
from .validator import validate_update
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},
"visibleWhen": {"directional": True},
},
"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 keep_only_known_params(next_params, PARAM_KEYS)
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},
)
__all__ = [
"LABEL",
"TOOLTIP",
"EDITABLE_PROPERTIES",
"CAPABILITIES",
"USE_SOUND",
"EMIT_SOUND",
"USE_COOLDOWN_MS",
"EMIT_RANGE",
"DIRECTIONAL",
"DEFAULT_TITLE",
"DEFAULT_PARAMS",
"PROPERTY_METADATA",
"EFFECT_OPTIONS",
"validate_update",
"use_item",
]

View File

@@ -0,0 +1,97 @@
"""Widget item validation/normalization."""
from __future__ import annotations
from ....models import WorldItem
from ...helpers import keep_only_known_params, parse_bool_like
from .definition import EFFECT_OPTIONS, PARAM_KEYS
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 keep_only_known_params(next_params, PARAM_KEYS)