diff --git a/docs/examples/item-type-sample/README.md b/docs/examples/item-type-sample/README.md new file mode 100644 index 0000000..631608c --- /dev/null +++ b/docs/examples/item-type-sample/README.md @@ -0,0 +1,14 @@ +# Sample Item Type Folder + +This is a reference layout for adding a new server item type plugin. + +## Folder Layout + +- `definition.py`: static metadata/defaults/schema constants. +- `validator.py`: `validate_update(item, next_params)` normalization and validation. +- `actions.py`: `use_item(item, nickname, clock_formatter)` runtime behavior. +- `module.py`: thin exported surface combining the three files. +- `plugin.py`: registration payload consumed by plugin auto-discovery. + +Use this folder as a copy template when creating a real item under: +`server/app/items/types//`. diff --git a/docs/examples/item-type-sample/actions.py b/docs/examples/item-type-sample/actions.py new file mode 100644 index 0000000..e095a09 --- /dev/null +++ b/docs/examples/item-type-sample/actions.py @@ -0,0 +1,19 @@ +"""Counter 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: + """Increment counter and return messages plus updated params.""" + + next_value = int(item.params.get("value", 0)) + 1 + return ItemUseResult( + self_message=f"{item.title}: {next_value}", + others_message=f"{nickname} uses {item.title}: {next_value}", + updated_params={**item.params, "value": next_value}, + ) diff --git a/docs/examples/item-type-sample/definition.py b/docs/examples/item-type-sample/definition.py new file mode 100644 index 0000000..6056937 --- /dev/null +++ b/docs/examples/item-type-sample/definition.py @@ -0,0 +1,21 @@ +"""Counter item static metadata and defaults.""" + +from __future__ import annotations + +LABEL = "counter" +TOOLTIP = "Simple incrementing counter." +EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "value") +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 = "counter" +DEFAULT_PARAMS: dict = {"value": 0} +PARAM_KEYS: tuple[str, ...] = ("value",) + +PROPERTY_METADATA: dict[str, dict[str, object]] = { + "title": {"valueType": "text", "tooltip": "Display name.", "maxLength": 80}, + "value": {"valueType": "number", "tooltip": "Current value.", "range": {"min": 0, "max": 9999, "step": 1}}, +} diff --git a/docs/examples/item-type-sample/module.py b/docs/examples/item-type-sample/module.py new file mode 100644 index 0000000..9b9895e --- /dev/null +++ b/docs/examples/item-type-sample/module.py @@ -0,0 +1,20 @@ +"""Counter item plugin module surface.""" + +from __future__ import annotations + +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 diff --git a/docs/examples/item-type-sample/plugin.py b/docs/examples/item-type-sample/plugin.py new file mode 100644 index 0000000..246d0eb --- /dev/null +++ b/docs/examples/item-type-sample/plugin.py @@ -0,0 +1,11 @@ +"""Counter plugin registration sample.""" + +from __future__ import annotations + +from . import module + +ITEM_TYPE_PLUGIN = { + "type": "counter", + "order": 25, + "module": module, +} diff --git a/docs/examples/item-type-sample/validator.py b/docs/examples/item-type-sample/validator.py new file mode 100644 index 0000000..9117e96 --- /dev/null +++ b/docs/examples/item-type-sample/validator.py @@ -0,0 +1,20 @@ +"""Counter 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 counter params.""" + + try: + value = int(next_params.get("value", 0)) + except (TypeError, ValueError) as exc: + raise ValueError("value must be a number.") from exc + if value < 0: + raise ValueError("value must be 0 or greater.") + next_params["value"] = value + return keep_only_known_params(next_params, PARAM_KEYS) diff --git a/docs/item-type-template.md b/docs/item-type-template.md index 71f2f78..8c48211 100644 --- a/docs/item-type-template.md +++ b/docs/item-type-template.md @@ -6,19 +6,13 @@ This page is the practical template for the current plugin-driven item architect When adding a new item type: -1. Server item module -- Add `server/app/items/types//module.py`. -- Define metadata/constants: - - `LABEL`, `TOOLTIP` - - `EDITABLE_PROPERTIES` - - `CAPABILITIES` - - `USE_SOUND`, `EMIT_SOUND` - - `USE_COOLDOWN_MS`, `EMIT_RANGE`, `DIRECTIONAL` - - `DEFAULT_TITLE`, `DEFAULT_PARAMS` - - `PROPERTY_METADATA` -- Implement behavior: - - `validate_update(item, next_params)` - - `use_item(item, nickname, clock_formatter)` +1. Server item package +- Add `server/app/items/types//` with: + - `definition.py` for metadata/constants + - `validator.py` for `validate_update(item, next_params)` + - `actions.py` for `use_item(item, nickname, clock_formatter)` + - `module.py` as thin exported surface + - `plugin.py` for registration 2. Server plugin file - Add `server/app/items/types//plugin.py` exporting: @@ -38,66 +32,16 @@ When adding a new item type: That is enough for a first working item type. -## Minimal Server Module Example: `counter` +## Reference Sample Folder -`server/app/items/types/counter/module.py`: +See `docs/examples/item-type-sample/` for a complete copyable folder with all five files. + +## Minimal `module.py` Example ```py -from __future__ import annotations - -from typing import Callable - -from ...item_types import ItemUseResult -from ...models import WorldItem - -LABEL = "counter" -TOOLTIP = "Counts up each time you use it." -EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "value") -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 = "counter" -DEFAULT_PARAMS: dict = {"value": 0} - -PROPERTY_METADATA: dict[str, dict[str, object]] = { - "title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."}, - "value": {"valueType": "number", "tooltip": "Current counter value.", "range": {"min": 0, "max": 9999, "step": 1}}, -} - - -def validate_update(_item: WorldItem, next_params: dict) -> dict: - try: - value = int(next_params.get("value", 0)) - except (TypeError, ValueError) as exc: - raise ValueError("value must be a number.") from exc - if value < 0: - raise ValueError("value must be 0 or greater.") - next_params["value"] = value - return next_params - - -def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult: - next_value = int(item.params.get("value", 0)) + 1 - return ItemUseResult( - self_message=f"{item.title}: {next_value}", - others_message=f"{nickname} uses {item.title}: {next_value}", - updated_params={**item.params, "value": next_value}, - ) -``` - -Then add plugin registration in `server/app/items/types/counter/plugin.py`: - -```py -from . import module - -ITEM_TYPE_PLUGIN = { - "type": "counter", - "order": 25, - "module": module, -} +from .actions import use_item +from .definition import LABEL, TOOLTIP, EDITABLE_PROPERTIES, CAPABILITIES, USE_SOUND, EMIT_SOUND, USE_COOLDOWN_MS, EMIT_RANGE, DIRECTIONAL, DEFAULT_TITLE, DEFAULT_PARAMS, PROPERTY_METADATA +from .validator import validate_update ``` ## Checklist Before Commit diff --git a/docs/item-types.md b/docs/item-types.md index 61d8eb8..488b009 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -193,10 +193,11 @@ Server is the source of truth for item type definitions and metadata. The client For a full copy/paste example with plain-English explanation, see `docs/item-type-template.md`. -1. Server item module: add a new file under `server/app/items/types//module.py` with: - - defaults/capabilities - - property metadata/options - - `validate_update` and `use_item` +1. Server item package: add a new folder under `server/app/items/types//` with: + - `definition.py` (defaults/capabilities/metadata/options) + - `validator.py` (`validate_update`) + - `actions.py` (`use_item`) + - `module.py` (thin exported surface) 2. Server plugin: add `server/app/items/types//plugin.py` exporting `ITEM_TYPE_PLUGIN` with: - `type` - `order` diff --git a/plans/item-architecture-refactor-plan.md b/plans/item-architecture-refactor-plan.md index 396168b..9e3f06a 100644 --- a/plans/item-architecture-refactor-plan.md +++ b/plans/item-architecture-refactor-plan.md @@ -298,5 +298,5 @@ When adding a new item type: ### Notes - Client item-specific runtime is now reduced to only `piano`; simple items (`dice`, `wheel`, `clock`, `radio_station`, `widget`) run through generic client flows with no custom behavior module. - Server item implementations now live inside per-type folders (`server/app/items/types/*/module.py`) and plugins point directly to those modules. -- Remaining optional future work: - - split server type modules into `definition.py`/`validator.py`/`actions.py` files per type if we want finer-grained plugin internals. +- Server type packages are now split into `definition.py` / `validator.py` / `actions.py` plus a thin `module.py` export surface. +- Added docs sample folder at `docs/examples/item-type-sample/` and updated template docs to reflect the package layout. diff --git a/server/app/items/types/clock/__init__.py b/server/app/items/types/clock/__init__.py index c30f073..597716d 100644 --- a/server/app/items/types/clock/__init__.py +++ b/server/app/items/types/clock/__init__.py @@ -1 +1,3 @@ -"""Item type plugin package.""" +"""Item type package exposing plugin module surface.""" + +from .module import * # noqa: F401,F403 diff --git a/server/app/items/types/clock/actions.py b/server/app/items/types/clock/actions.py new file mode 100644 index 0000000..8296a13 --- /dev/null +++ b/server/app/items/types/clock/actions.py @@ -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}.", + ) diff --git a/server/app/items/types/clock/definition.py b/server/app/items/types/clock/definition.py new file mode 100644 index 0000000..cbd8009 --- /dev/null +++ b/server/app/items/types/clock/definition.py @@ -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."}, +} diff --git a/server/app/items/types/clock/module.py b/server/app/items/types/clock/module.py index f886406..f28fbc8 100644 --- a/server/app/items/types/clock/module.py +++ b/server/app/items/types/clock/module.py @@ -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", +] diff --git a/server/app/items/types/clock/validator.py b/server/app/items/types/clock/validator.py new file mode 100644 index 0000000..08269c2 --- /dev/null +++ b/server/app/items/types/clock/validator.py @@ -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) diff --git a/server/app/items/types/dice/__init__.py b/server/app/items/types/dice/__init__.py index c30f073..597716d 100644 --- a/server/app/items/types/dice/__init__.py +++ b/server/app/items/types/dice/__init__.py @@ -1 +1,3 @@ -"""Item type plugin package.""" +"""Item type package exposing plugin module surface.""" + +from .module import * # noqa: F401,F403 diff --git a/server/app/items/types/dice/actions.py b/server/app/items/types/dice/actions.py new file mode 100644 index 0000000..98e79fc --- /dev/null +++ b/server/app/items/types/dice/actions.py @@ -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}).", + ) diff --git a/server/app/items/types/dice/definition.py b/server/app/items/types/dice/definition.py new file mode 100644 index 0000000..421367b --- /dev/null +++ b/server/app/items/types/dice/definition.py @@ -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}, + }, +} diff --git a/server/app/items/types/dice/module.py b/server/app/items/types/dice/module.py index 2e85929..349fb6b 100644 --- a/server/app/items/types/dice/module.py +++ b/server/app/items/types/dice/module.py @@ -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", +] diff --git a/server/app/items/types/dice/validator.py b/server/app/items/types/dice/validator.py new file mode 100644 index 0000000..cb13f4b --- /dev/null +++ b/server/app/items/types/dice/validator.py @@ -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) diff --git a/server/app/items/types/piano/__init__.py b/server/app/items/types/piano/__init__.py index c30f073..597716d 100644 --- a/server/app/items/types/piano/__init__.py +++ b/server/app/items/types/piano/__init__.py @@ -1 +1,3 @@ -"""Item type plugin package.""" +"""Item type package exposing plugin module surface.""" + +from .module import * # noqa: F401,F403 diff --git a/server/app/items/types/piano/actions.py b/server/app/items/types/piano/actions.py new file mode 100644 index 0000000..656d6c6 --- /dev/null +++ b/server/app/items/types/piano/actions.py @@ -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}.", + ) diff --git a/server/app/items/types/piano/definition.py b/server/app/items/types/piano/definition.py new file mode 100644 index 0000000..04fe432 --- /dev/null +++ b/server/app/items/types/piano/definition.py @@ -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}, + }, +} diff --git a/server/app/items/types/piano/module.py b/server/app/items/types/piano/module.py index 11523c4..29f5251 100644 --- a/server/app/items/types/piano/module.py +++ b/server/app/items/types/piano/module.py @@ -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", +] diff --git a/server/app/items/types/piano/validator.py b/server/app/items/types/piano/validator.py new file mode 100644 index 0000000..e9b9aa5 --- /dev/null +++ b/server/app/items/types/piano/validator.py @@ -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) diff --git a/server/app/items/types/radio_station/__init__.py b/server/app/items/types/radio_station/__init__.py index c30f073..597716d 100644 --- a/server/app/items/types/radio_station/__init__.py +++ b/server/app/items/types/radio_station/__init__.py @@ -1 +1,3 @@ -"""Item type plugin package.""" +"""Item type package exposing plugin module surface.""" + +from .module import * # noqa: F401,F403 diff --git a/server/app/items/types/radio_station/actions.py b/server/app/items/types/radio_station/actions.py new file mode 100644 index 0000000..0c5079e --- /dev/null +++ b/server/app/items/types/radio_station/actions.py @@ -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}, + ) diff --git a/server/app/items/types/radio_station/definition.py b/server/app/items/types/radio_station/definition.py new file mode 100644 index 0000000..24b8c55 --- /dev/null +++ b/server/app/items/types/radio_station/definition.py @@ -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}, + }, +} diff --git a/server/app/items/types/radio_station/module.py b/server/app/items/types/radio_station/module.py index 8cc5385..9b57e04 100644 --- a/server/app/items/types/radio_station/module.py +++ b/server/app/items/types/radio_station/module.py @@ -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", +] diff --git a/server/app/items/types/radio_station/validator.py b/server/app/items/types/radio_station/validator.py new file mode 100644 index 0000000..6aa3037 --- /dev/null +++ b/server/app/items/types/radio_station/validator.py @@ -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) diff --git a/server/app/items/types/wheel/__init__.py b/server/app/items/types/wheel/__init__.py index c30f073..597716d 100644 --- a/server/app/items/types/wheel/__init__.py +++ b/server/app/items/types/wheel/__init__.py @@ -1 +1,3 @@ -"""Item type plugin package.""" +"""Item type package exposing plugin module surface.""" + +from .module import * # noqa: F401,F403 diff --git a/server/app/items/types/wheel/actions.py b/server/app/items/types/wheel/actions.py new file mode 100644 index 0000000..5a38dc3 --- /dev/null +++ b/server/app/items/types/wheel/actions.py @@ -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, + ) diff --git a/server/app/items/types/wheel/definition.py b/server/app/items/types/wheel/definition.py new file mode 100644 index 0000000..c7a83a4 --- /dev/null +++ b/server/app/items/types/wheel/definition.py @@ -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, + }, +} diff --git a/server/app/items/types/wheel/module.py b/server/app/items/types/wheel/module.py index 8009d1d..cd199d3 100644 --- a/server/app/items/types/wheel/module.py +++ b/server/app/items/types/wheel/module.py @@ -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", +] diff --git a/server/app/items/types/wheel/validator.py b/server/app/items/types/wheel/validator.py new file mode 100644 index 0000000..f1e8f11 --- /dev/null +++ b/server/app/items/types/wheel/validator.py @@ -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) diff --git a/server/app/items/types/widget/__init__.py b/server/app/items/types/widget/__init__.py index c30f073..597716d 100644 --- a/server/app/items/types/widget/__init__.py +++ b/server/app/items/types/widget/__init__.py @@ -1 +1,3 @@ -"""Item type plugin package.""" +"""Item type package exposing plugin module surface.""" + +from .module import * # noqa: F401,F403 diff --git a/server/app/items/types/widget/actions.py b/server/app/items/types/widget/actions.py new file mode 100644 index 0000000..4b1ad13 --- /dev/null +++ b/server/app/items/types/widget/actions.py @@ -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}, + ) diff --git a/server/app/items/types/widget/definition.py b/server/app/items/types/widget/definition.py new file mode 100644 index 0000000..087252e --- /dev/null +++ b/server/app/items/types/widget/definition.py @@ -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, + }, +} diff --git a/server/app/items/types/widget/module.py b/server/app/items/types/widget/module.py index 5d4d931..f52ab8d 100644 --- a/server/app/items/types/widget/module.py +++ b/server/app/items/types/widget/module.py @@ -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", +] diff --git a/server/app/items/types/widget/validator.py b/server/app/items/types/widget/validator.py new file mode 100644 index 0000000..e9754cb --- /dev/null +++ b/server/app/items/types/widget/validator.py @@ -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)