diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 9477f6b..3cadb33 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -5,65 +5,27 @@ from __future__ import annotations from dataclasses import dataclass from typing import Literal +from .items import clock, dice, radio, wheel + ItemType = Literal["radio_station", "dice", "wheel", "clock"] ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = ("clock", "dice", "radio_station", "wheel") ITEM_TYPE_LABELS: dict[ItemType, str] = { - "radio_station": "radio", - "dice": "dice", - "wheel": "wheel", - "clock": "clock", + "radio_station": radio.LABEL, + "dice": dice.LABEL, + "wheel": wheel.LABEL, + "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, ...]] = { - "radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue", "facing", "emitRange"), - "dice": ("title", "sides", "number"), - "wheel": ("title", "spaces"), - "clock": ("title", "timeZone", "use24Hour"), + "radio_station": radio.EDITABLE_PROPERTIES, + "dice": dice.EDITABLE_PROPERTIES, + "wheel": wheel.EDITABLE_PROPERTIES, + "clock": clock.EDITABLE_PROPERTIES, } -CLOCK_DEFAULT_TIME_ZONE = "America/Detroit" -CLOCK_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", -) + +CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE +CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS +RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS +RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS @dataclass(frozen=True) @@ -80,47 +42,71 @@ class ItemDefinition: 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] = { - "radio_station": ItemDefinition( - default_title="radio", - capabilities=("editable", "carryable", "deletable", "usable"), - use_sound=None, - emit_sound=None, - default_params={ - "streamUrl": "", - "enabled": True, - "channel": "stereo", - "volume": 50, - "effect": "off", - "effectValue": 50, - "facing": 0, - "emitRange": 20, - }, - emit_range=20, - directional=True, + "radio_station": _build_definition( + default_title=radio.DEFAULT_TITLE, + capabilities=radio.CAPABILITIES, + use_sound=radio.USE_SOUND, + emit_sound=radio.EMIT_SOUND, + default_params=radio.DEFAULT_PARAMS, + use_cooldown_ms=radio.USE_COOLDOWN_MS, + emit_range=radio.EMIT_RANGE, + directional=radio.DIRECTIONAL, ), - "dice": ItemDefinition( - default_title="Dice", - capabilities=("editable", "carryable", "deletable", "usable"), - use_sound="sounds/roll.ogg", - emit_sound=None, - default_params={"sides": 6, "number": 2}, + "dice": _build_definition( + default_title=dice.DEFAULT_TITLE, + capabilities=dice.CAPABILITIES, + use_sound=dice.USE_SOUND, + emit_sound=dice.EMIT_SOUND, + default_params=dice.DEFAULT_PARAMS, + use_cooldown_ms=dice.USE_COOLDOWN_MS, + emit_range=dice.EMIT_RANGE, + directional=dice.DIRECTIONAL, ), - "wheel": ItemDefinition( - default_title="wheel", - capabilities=("editable", "carryable", "deletable", "usable"), - use_sound="sounds/spin.ogg", - emit_sound=None, - default_params={"spaces": "yes, no"}, - use_cooldown_ms=4000, + "wheel": _build_definition( + default_title=wheel.DEFAULT_TITLE, + capabilities=wheel.CAPABILITIES, + use_sound=wheel.USE_SOUND, + emit_sound=wheel.EMIT_SOUND, + default_params=wheel.DEFAULT_PARAMS, + use_cooldown_ms=wheel.USE_COOLDOWN_MS, + emit_range=wheel.EMIT_RANGE, + directional=wheel.DIRECTIONAL, ), - "clock": ItemDefinition( - default_title="clock", - capabilities=("editable", "carryable", "deletable", "usable"), - use_sound=None, - emit_sound="sounds/clock.ogg", - default_params={"timeZone": CLOCK_DEFAULT_TIME_ZONE, "use24Hour": False}, - emit_range=10, + "clock": _build_definition( + default_title=clock.DEFAULT_TITLE, + capabilities=clock.CAPABILITIES, + use_sound=clock.USE_SOUND, + emit_sound=clock.EMIT_SOUND, + default_params=clock.DEFAULT_PARAMS, + 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] = { - "radio_station": "Can play stations from the Internet. Tune multiple to the same station and they will sync up.", - "dice": "Great for drinking games or boredom.", - "wheel": "Spin to win fabulous prizes.", - "clock": "It tells the time. What did you think it did?", + "radio_station": radio.TOOLTIP, + "dice": dice.TOOLTIP, + "wheel": wheel.TOOLTIP, + "clock": clock.TOOLTIP, } 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]]] = { - "radio_station": { - **GLOBAL_ITEM_PROPERTY_METADATA, - "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}, - }, - }, - "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."}, - }, + "radio_station": {**GLOBAL_ITEM_PROPERTY_METADATA, **radio.PROPERTY_METADATA}, + "dice": {**GLOBAL_ITEM_PROPERTY_METADATA, **dice.PROPERTY_METADATA}, + "wheel": {**GLOBAL_ITEM_PROPERTY_METADATA, **wheel.PROPERTY_METADATA}, + "clock": {**GLOBAL_ITEM_PROPERTY_METADATA, **clock.PROPERTY_METADATA}, } @@ -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, "directional": bool(definition.directional), } + diff --git a/server/app/item_type_handlers.py b/server/app/item_type_handlers.py index 06ad75f..c6aa3cb 100644 --- a/server/app/item_type_handlers.py +++ b/server/app/item_type_handlers.py @@ -1,249 +1,16 @@ -"""Per-item-type use/update handlers for modular item behavior.""" +"""Per-item-type handler registry.""" from __future__ import annotations -from dataclasses import dataclass -import random -from typing import Callable - -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] - +from .item_catalog import ItemType +from .items import clock, dice, radio, wheel +from .item_types import ItemTypeHandler ITEM_TYPE_HANDLERS: dict[ItemType, ItemTypeHandler] = { - "radio_station": ItemTypeHandler(validate_update=_validate_radio_update, use=_use_radio), - "dice": ItemTypeHandler(validate_update=_validate_dice_update, use=_use_dice), - "wheel": ItemTypeHandler(validate_update=_validate_wheel_update, use=_use_wheel), - "clock": ItemTypeHandler(validate_update=_validate_clock_update, use=_use_clock), + "radio_station": ItemTypeHandler(validate_update=radio.validate_update, use=radio.use_item), + "dice": ItemTypeHandler(validate_update=dice.validate_update, use=dice.use_item), + "wheel": ItemTypeHandler(validate_update=wheel.validate_update, use=wheel.use_item), + "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.""" return ITEM_TYPE_HANDLERS[item_type] + diff --git a/server/app/item_types.py b/server/app/item_types.py new file mode 100644 index 0000000..3ba0549 --- /dev/null +++ b/server/app/item_types.py @@ -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] diff --git a/server/app/items/__init__.py b/server/app/items/__init__.py new file mode 100644 index 0000000..37d3683 --- /dev/null +++ b/server/app/items/__init__.py @@ -0,0 +1,2 @@ +"""Per-item modules containing item-specific schema and behavior.""" + diff --git a/server/app/items/clock.py b/server/app/items/clock.py new file mode 100644 index 0000000..ae0575f --- /dev/null +++ b/server/app/items/clock.py @@ -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}.", + ) + diff --git a/server/app/items/dice.py b/server/app/items/dice.py new file mode 100644 index 0000000..fc3ee17 --- /dev/null +++ b/server/app/items/dice.py @@ -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}).", + ) + diff --git a/server/app/items/helpers.py b/server/app/items/helpers.py new file mode 100644 index 0000000..5add62e --- /dev/null +++ b/server/app/items/helpers.py @@ -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 + diff --git a/server/app/items/radio.py b/server/app/items/radio.py new file mode 100644 index 0000000..c1bf59f --- /dev/null +++ b/server/app/items/radio.py @@ -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}, + ) + diff --git a/server/app/items/wheel.py b/server/app/items/wheel.py new file mode 100644 index 0000000..0385ec3 --- /dev/null +++ b/server/app/items/wheel.py @@ -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, + ) +