refactor: split item type packages into definition/validator/actions
This commit is contained in:
14
docs/examples/item-type-sample/README.md
Normal file
14
docs/examples/item-type-sample/README.md
Normal file
@@ -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/<item_type>/`.
|
||||
19
docs/examples/item-type-sample/actions.py
Normal file
19
docs/examples/item-type-sample/actions.py
Normal file
@@ -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},
|
||||
)
|
||||
21
docs/examples/item-type-sample/definition.py
Normal file
21
docs/examples/item-type-sample/definition.py
Normal file
@@ -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}},
|
||||
}
|
||||
20
docs/examples/item-type-sample/module.py
Normal file
20
docs/examples/item-type-sample/module.py
Normal file
@@ -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
|
||||
11
docs/examples/item-type-sample/plugin.py
Normal file
11
docs/examples/item-type-sample/plugin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""Counter plugin registration sample."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import module
|
||||
|
||||
ITEM_TYPE_PLUGIN = {
|
||||
"type": "counter",
|
||||
"order": 25,
|
||||
"module": module,
|
||||
}
|
||||
20
docs/examples/item-type-sample/validator.py
Normal file
20
docs/examples/item-type-sample/validator.py
Normal file
@@ -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)
|
||||
@@ -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/<item_type>/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/<item_type>/` 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/<item_type>/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
|
||||
|
||||
@@ -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/<item_type>/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/<item_type>/` 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/<item_type>/plugin.py` exporting `ITEM_TYPE_PLUGIN` with:
|
||||
- `type`
|
||||
- `order`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
"""Item type plugin package."""
|
||||
"""Item type package exposing plugin module surface."""
|
||||
|
||||
from .module import * # noqa: F401,F403
|
||||
|
||||
18
server/app/items/types/clock/actions.py
Normal file
18
server/app/items/types/clock/actions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Clock item use actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ....item_types import ItemUseResult
|
||||
from ....models import WorldItem
|
||||
|
||||
|
||||
def use_item(item: WorldItem, nickname: str, clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||
"""Read current clock time based on item configuration."""
|
||||
|
||||
display_time = clock_formatter(item.params)
|
||||
return ItemUseResult(
|
||||
self_message=f"{item.title} says {display_time}.",
|
||||
others_message=f"{nickname} checks {item.title}. {item.title} says {display_time}.",
|
||||
)
|
||||
65
server/app/items/types/clock/definition.py
Normal file
65
server/app/items/types/clock/definition.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Clock item static metadata and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABEL = "clock"
|
||||
TOOLTIP = "It tells the time. What did you think it did?"
|
||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "timeZone", "use24Hour")
|
||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||
USE_SOUND: str | None = None
|
||||
EMIT_SOUND = "sounds/clock.ogg"
|
||||
USE_COOLDOWN_MS = 1000
|
||||
EMIT_RANGE = 10
|
||||
DIRECTIONAL = False
|
||||
DEFAULT_TITLE = "clock"
|
||||
DEFAULT_TIME_ZONE = "America/Detroit"
|
||||
TIME_ZONE_OPTIONS: tuple[str, ...] = (
|
||||
"America/Anchorage",
|
||||
"America/Argentina/Buenos_Aires",
|
||||
"America/Chicago",
|
||||
"America/Detroit",
|
||||
"America/Halifax",
|
||||
"America/Indiana/Indianapolis",
|
||||
"America/Kentucky/Louisville",
|
||||
"America/Los_Angeles",
|
||||
"America/St_Johns",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Dhaka",
|
||||
"Asia/Dubai",
|
||||
"Asia/Hong_Kong",
|
||||
"Asia/Kabul",
|
||||
"Asia/Karachi",
|
||||
"Asia/Kathmandu",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Seoul",
|
||||
"Asia/Singapore",
|
||||
"Asia/Tehran",
|
||||
"Asia/Tokyo",
|
||||
"Asia/Yangon",
|
||||
"Atlantic/Azores",
|
||||
"Atlantic/South_Georgia",
|
||||
"Australia/Brisbane",
|
||||
"Australia/Darwin",
|
||||
"Australia/Eucla",
|
||||
"Australia/Lord_Howe",
|
||||
"Europe/Berlin",
|
||||
"Europe/Helsinki",
|
||||
"Europe/London",
|
||||
"Europe/Moscow",
|
||||
"Pacific/Apia",
|
||||
"Pacific/Auckland",
|
||||
"Pacific/Chatham",
|
||||
"Pacific/Honolulu",
|
||||
"Pacific/Kiritimati",
|
||||
"Pacific/Noumea",
|
||||
"Pacific/Pago_Pago",
|
||||
"UTC",
|
||||
)
|
||||
DEFAULT_PARAMS: dict = {"timeZone": DEFAULT_TIME_ZONE, "use24Hour": False}
|
||||
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
|
||||
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
21
server/app/items/types/clock/validator.py
Normal file
21
server/app/items/types/clock/validator.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Clock item validation/normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....models import WorldItem
|
||||
from ...helpers import keep_only_known_params, parse_bool_like_or_none
|
||||
from .definition import DEFAULT_TIME_ZONE, PARAM_KEYS, TIME_ZONE_OPTIONS
|
||||
|
||||
|
||||
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||
"""Validate and normalize clock params."""
|
||||
|
||||
time_zone = str(next_params.get("timeZone", DEFAULT_TIME_ZONE)).strip()
|
||||
if time_zone not in TIME_ZONE_OPTIONS:
|
||||
raise ValueError(f"timeZone must be one of {', '.join(TIME_ZONE_OPTIONS)}.")
|
||||
use_24_hour = parse_bool_like_or_none(next_params.get("use24Hour"))
|
||||
if use_24_hour is None:
|
||||
raise ValueError("use24Hour must be on/off.")
|
||||
next_params["timeZone"] = time_zone
|
||||
next_params["use24Hour"] = use_24_hour
|
||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||
@@ -1 +1,3 @@
|
||||
"""Item type plugin package."""
|
||||
"""Item type package exposing plugin module surface."""
|
||||
|
||||
from .module import * # noqa: F401,F403
|
||||
|
||||
32
server/app/items/types/dice/actions.py
Normal file
32
server/app/items/types/dice/actions.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Dice item use actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Callable
|
||||
|
||||
from ....item_types import ItemUseResult
|
||||
from ....models import WorldItem
|
||||
|
||||
|
||||
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||
"""Roll dice and report result."""
|
||||
|
||||
try:
|
||||
sides = max(1, min(100, int(item.params.get("sides", 6))))
|
||||
number = max(1, min(100, int(item.params.get("number", 2))))
|
||||
except (TypeError, ValueError):
|
||||
sides = 6
|
||||
number = 2
|
||||
rolls = [random.randint(1, sides) for _ in range(number)]
|
||||
total = sum(rolls)
|
||||
rolls_text = ", ".join(str(value) for value in rolls)
|
||||
if number == 1:
|
||||
return ItemUseResult(
|
||||
self_message=f"You rolled {item.title}: {rolls_text}.",
|
||||
others_message=f"{nickname} rolled {item.title}: {rolls_text}.",
|
||||
)
|
||||
return ItemUseResult(
|
||||
self_message=f"You rolled {item.title}: {rolls_text} (total {total}).",
|
||||
others_message=f"{nickname} rolled {item.title}: {rolls_text} (total {total}).",
|
||||
)
|
||||
30
server/app/items/types/dice/definition.py
Normal file
30
server/app/items/types/dice/definition.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Dice item static metadata and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABEL = "dice"
|
||||
TOOLTIP = "Great for drinking games or boredom."
|
||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "sides", "number")
|
||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||
USE_SOUND = "sounds/roll.ogg"
|
||||
EMIT_SOUND: str | None = None
|
||||
USE_COOLDOWN_MS = 1000
|
||||
EMIT_RANGE = 15
|
||||
DIRECTIONAL = False
|
||||
DEFAULT_TITLE = "Dice"
|
||||
DEFAULT_PARAMS: dict = {"sides": 6, "number": 2}
|
||||
PARAM_KEYS: tuple[str, ...] = ("sides", "number")
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"sides": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Number of sides on each die.",
|
||||
"range": {"min": 1, "max": 100, "step": 1},
|
||||
},
|
||||
"number": {
|
||||
"valueType": "number",
|
||||
"tooltip": "How many dice to roll per use.",
|
||||
"range": {"min": 1, "max": 100, "step": 1},
|
||||
},
|
||||
}
|
||||
@@ -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 ....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}).",
|
||||
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
|
||||
|
||||
__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",
|
||||
]
|
||||
|
||||
22
server/app/items/types/dice/validator.py
Normal file
22
server/app/items/types/dice/validator.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Dice item validation/normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....models import WorldItem
|
||||
from ...helpers import keep_only_known_params
|
||||
from .definition import PARAM_KEYS
|
||||
|
||||
|
||||
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||
"""Validate and normalize dice params."""
|
||||
|
||||
try:
|
||||
sides = int(next_params.get("sides", 6))
|
||||
number = int(next_params.get("number", 2))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Dice values must be numbers.") from exc
|
||||
if not (1 <= sides <= 100 and 1 <= number <= 100):
|
||||
raise ValueError("Dice sides and number must be between 1 and 100.")
|
||||
next_params["sides"] = sides
|
||||
next_params["number"] = number
|
||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||
@@ -1 +1,3 @@
|
||||
"""Item type plugin package."""
|
||||
"""Item type package exposing plugin module surface."""
|
||||
|
||||
from .module import * # noqa: F401,F403
|
||||
|
||||
17
server/app/items/types/piano/actions.py
Normal file
17
server/app/items/types/piano/actions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""Piano item use actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ....item_types import ItemUseResult
|
||||
from ....models import WorldItem
|
||||
|
||||
|
||||
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||
"""Enter piano play mode for the user who used the item."""
|
||||
|
||||
return ItemUseResult(
|
||||
self_message=f"You begin playing {item.title}.",
|
||||
others_message=f"{nickname} begins playing {item.title}.",
|
||||
)
|
||||
99
server/app/items/types/piano/definition.py
Normal file
99
server/app/items/types/piano/definition.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Piano item static metadata and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABEL = "piano"
|
||||
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
|
||||
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
||||
"title",
|
||||
"instrument",
|
||||
"voiceMode",
|
||||
"octave",
|
||||
"attack",
|
||||
"decay",
|
||||
"release",
|
||||
"brightness",
|
||||
"emitRange",
|
||||
)
|
||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||
USE_SOUND: str | None = None
|
||||
EMIT_SOUND: str | None = None
|
||||
USE_COOLDOWN_MS = 1000
|
||||
EMIT_RANGE = 15
|
||||
DIRECTIONAL = False
|
||||
DEFAULT_TITLE = "piano"
|
||||
DEFAULT_PARAMS: dict = {
|
||||
"instrument": "piano",
|
||||
"voiceMode": "poly",
|
||||
"octave": 0,
|
||||
"attack": 15,
|
||||
"decay": 45,
|
||||
"release": 35,
|
||||
"brightness": 55,
|
||||
"emitRange": 15,
|
||||
"songId": "unterlandersheimweh",
|
||||
}
|
||||
PARAM_KEYS: tuple[str, ...] = ("instrument", "voiceMode", "octave", "attack", "decay", "release", "brightness", "emitRange", "songId")
|
||||
|
||||
INSTRUMENT_OPTIONS: tuple[str, ...] = (
|
||||
"piano",
|
||||
"electric_piano",
|
||||
"guitar",
|
||||
"organ",
|
||||
"bass",
|
||||
"violin",
|
||||
"synth_lead",
|
||||
"brass",
|
||||
"nintendo",
|
||||
"drum_kit",
|
||||
)
|
||||
VOICE_MODE_OPTIONS: tuple[str, ...] = ("poly", "mono")
|
||||
|
||||
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] = {
|
||||
"piano": (15, 45, 35, 55, "poly", 0),
|
||||
"electric_piano": (12, 40, 30, 62, "poly", 0),
|
||||
"guitar": (8, 35, 25, 50, "poly", 0),
|
||||
"organ": (25, 70, 45, 48, "poly", 0),
|
||||
"bass": (2, 24, 18, 34, "mono", -1),
|
||||
"violin": (22, 75, 55, 58, "mono", 0),
|
||||
"synth_lead": (6, 30, 22, 72, "poly", 0),
|
||||
"brass": (10, 45, 30, 60, "mono", 0),
|
||||
"nintendo": (1, 24, 15, 85, "poly", 0),
|
||||
"drum_kit": (1, 22, 12, 68, "poly", 0),
|
||||
}
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano."},
|
||||
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords."},
|
||||
"octave": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",
|
||||
"range": {"min": -2, "max": 2, "step": 1},
|
||||
},
|
||||
"attack": {
|
||||
"valueType": "number",
|
||||
"tooltip": "How quickly notes ramp in. Lower is sharper; higher is softer.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"decay": {
|
||||
"valueType": "number",
|
||||
"tooltip": "How long notes ring out after the initial hit.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"release": {
|
||||
"valueType": "number",
|
||||
"tooltip": "How long notes continue after key release.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"brightness": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Tone brightness; higher values sound brighter.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"emitRange": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Maximum distance in squares where this piano can be heard.",
|
||||
"range": {"min": 5, "max": 20, "step": 1},
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
86
server/app/items/types/piano/validator.py
Normal file
86
server/app/items/types/piano/validator.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Piano item validation/normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....models import WorldItem
|
||||
from ...helpers import keep_only_known_params
|
||||
from .definition import DEFAULT_ENVELOPE_BY_INSTRUMENT, INSTRUMENT_OPTIONS, PARAM_KEYS, VOICE_MODE_OPTIONS
|
||||
|
||||
|
||||
def validate_update(item: WorldItem, next_params: dict) -> dict:
|
||||
"""Validate and normalize piano params."""
|
||||
|
||||
# Song references are server-managed and not directly editable from client updates.
|
||||
preserved_song_id = item.params.get("songId")
|
||||
next_params.pop("songId", None)
|
||||
|
||||
instrument = str(next_params.get("instrument", "piano")).strip().lower()
|
||||
if instrument not in INSTRUMENT_OPTIONS:
|
||||
raise ValueError(f"instrument must be one of: {', '.join(INSTRUMENT_OPTIONS)}.")
|
||||
previous_instrument = str(item.params.get("instrument", "piano")).strip().lower()
|
||||
next_params["instrument"] = instrument
|
||||
|
||||
voice_mode = str(next_params.get("voiceMode", item.params.get("voiceMode", "poly"))).strip().lower()
|
||||
if voice_mode not in VOICE_MODE_OPTIONS:
|
||||
raise ValueError("voiceMode must be one of: poly, mono.")
|
||||
next_params["voiceMode"] = voice_mode
|
||||
|
||||
try:
|
||||
octave = int(next_params.get("octave", item.params.get("octave", 0)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("octave must be an integer between -2 and 2.") from exc
|
||||
if not (-2 <= octave <= 2):
|
||||
raise ValueError("octave must be between -2 and 2.")
|
||||
next_params["octave"] = octave
|
||||
|
||||
try:
|
||||
attack = int(next_params.get("attack", 15))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("attack must be an integer between 0 and 100.") from exc
|
||||
if not (0 <= attack <= 100):
|
||||
raise ValueError("attack must be between 0 and 100.")
|
||||
try:
|
||||
decay = int(next_params.get("decay", 45))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("decay must be an integer between 0 and 100.") from exc
|
||||
if not (0 <= decay <= 100):
|
||||
raise ValueError("decay must be between 0 and 100.")
|
||||
|
||||
try:
|
||||
release = int(next_params.get("release", 35))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("release must be an integer between 0 and 100.") from exc
|
||||
if not (0 <= release <= 100):
|
||||
raise ValueError("release must be between 0 and 100.")
|
||||
|
||||
try:
|
||||
brightness = int(next_params.get("brightness", 55))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("brightness must be an integer between 0 and 100.") from exc
|
||||
if not (0 <= brightness <= 100):
|
||||
raise ValueError("brightness must be between 0 and 100.")
|
||||
|
||||
# When instrument changes, reset envelope to instrument-appropriate defaults.
|
||||
if instrument != previous_instrument:
|
||||
attack, decay, release, brightness, voice_mode, octave = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(
|
||||
instrument, (15, 45, 35, 55, "poly", 0)
|
||||
)
|
||||
next_params["voiceMode"] = voice_mode
|
||||
next_params["octave"] = octave
|
||||
next_params["attack"] = attack
|
||||
next_params["decay"] = decay
|
||||
next_params["release"] = release
|
||||
next_params["brightness"] = brightness
|
||||
|
||||
try:
|
||||
emit_range = int(next_params.get("emitRange", 15))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitRange must be an integer between 5 and 20.") from exc
|
||||
if not (5 <= emit_range <= 20):
|
||||
raise ValueError("emitRange must be between 5 and 20.")
|
||||
next_params["emitRange"] = emit_range
|
||||
|
||||
if isinstance(preserved_song_id, str) and preserved_song_id.strip():
|
||||
next_params["songId"] = preserved_song_id.strip()
|
||||
|
||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||
@@ -1 +1,3 @@
|
||||
"""Item type plugin package."""
|
||||
"""Item type package exposing plugin module surface."""
|
||||
|
||||
from .module import * # noqa: F401,F403
|
||||
|
||||
21
server/app/items/types/radio_station/actions.py
Normal file
21
server/app/items/types/radio_station/actions.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Radio item use actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ....item_types import ItemUseResult
|
||||
from ....models import WorldItem
|
||||
from ...helpers import toggle_bool_param
|
||||
|
||||
|
||||
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||
"""Toggle radio on/off when used."""
|
||||
|
||||
next_enabled = toggle_bool_param(item.params, "enabled", default=True)
|
||||
state_text = "on" if next_enabled else "off"
|
||||
return ItemUseResult(
|
||||
self_message=f"You turn {state_text} {item.title}.",
|
||||
others_message=f"{nickname} turns {state_text} {item.title}.",
|
||||
updated_params={**item.params, "enabled": next_enabled},
|
||||
)
|
||||
76
server/app/items/types/radio_station/definition.py
Normal file
76
server/app/items/types/radio_station/definition.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Radio item static metadata and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABEL = "radio"
|
||||
TOOLTIP = "Can play stations from the Internet. Tune multiple to the same station and they will sync up."
|
||||
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
||||
"title",
|
||||
"streamUrl",
|
||||
"enabled",
|
||||
"mediaVolume",
|
||||
"mediaChannel",
|
||||
"mediaEffect",
|
||||
"mediaEffectValue",
|
||||
"facing",
|
||||
"emitRange",
|
||||
)
|
||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||
USE_SOUND: str | None = None
|
||||
EMIT_SOUND: str | None = None
|
||||
USE_COOLDOWN_MS = 1000
|
||||
EMIT_RANGE = 20
|
||||
DIRECTIONAL = True
|
||||
DEFAULT_TITLE = "radio"
|
||||
DEFAULT_PARAMS: dict = {
|
||||
"streamUrl": "",
|
||||
"enabled": True,
|
||||
"mediaVolume": 50,
|
||||
"mediaChannel": "stereo",
|
||||
"mediaEffect": "off",
|
||||
"mediaEffectValue": 50,
|
||||
"facing": 0,
|
||||
"emitRange": 20,
|
||||
}
|
||||
PARAM_KEYS: tuple[str, ...] = (
|
||||
"streamUrl",
|
||||
"enabled",
|
||||
"mediaVolume",
|
||||
"mediaChannel",
|
||||
"mediaEffect",
|
||||
"mediaEffectValue",
|
||||
"facing",
|
||||
"emitRange",
|
||||
)
|
||||
|
||||
CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right")
|
||||
EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio.", "maxLength": 2048},
|
||||
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
|
||||
"mediaVolume": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Playback media volume percent for this radio.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
||||
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
||||
"mediaEffectValue": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Amount for the selected effect.",
|
||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||
},
|
||||
"facing": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Facing direction in degrees used for directional emit.",
|
||||
"range": {"min": 0, "max": 360, "step": 1},
|
||||
"visibleWhen": {"directional": True},
|
||||
},
|
||||
"emitRange": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Maximum distance in squares for this radio's emitted audio.",
|
||||
"range": {"min": 5, "max": 20, "step": 1},
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
76
server/app/items/types/radio_station/validator.py
Normal file
76
server/app/items/types/radio_station/validator.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Radio item validation/normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....models import WorldItem
|
||||
from ...helpers import keep_only_known_params
|
||||
from .definition import CHANNEL_OPTIONS, EFFECT_OPTIONS, PARAM_KEYS
|
||||
|
||||
|
||||
def validate_update(item: WorldItem, next_params: dict) -> dict:
|
||||
"""Validate and normalize radio params."""
|
||||
|
||||
stream_url = str(next_params.get("streamUrl", "")).strip()
|
||||
if len(stream_url) > 2048:
|
||||
raise ValueError("streamUrl must be 2048 characters or less.")
|
||||
next_params["streamUrl"] = stream_url
|
||||
|
||||
enabled_value = next_params.get("enabled", True)
|
||||
if isinstance(enabled_value, bool):
|
||||
enabled = enabled_value
|
||||
elif isinstance(enabled_value, (int, float)):
|
||||
enabled = bool(enabled_value)
|
||||
elif isinstance(enabled_value, str):
|
||||
token = enabled_value.strip().lower()
|
||||
if token in {"on", "true", "1", "yes"}:
|
||||
enabled = True
|
||||
elif token in {"off", "false", "0", "no"}:
|
||||
enabled = False
|
||||
else:
|
||||
raise ValueError("enabled must be true/false or on/off.")
|
||||
else:
|
||||
raise ValueError("enabled must be true/false or on/off.")
|
||||
next_params["enabled"] = enabled
|
||||
|
||||
try:
|
||||
media_volume = int(next_params.get("mediaVolume", 50))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("mediaVolume must be a number.") from exc
|
||||
if not (0 <= media_volume <= 100):
|
||||
raise ValueError("mediaVolume must be between 0 and 100.")
|
||||
next_params["mediaVolume"] = media_volume
|
||||
|
||||
effect = str(next_params.get("mediaEffect", "off")).strip().lower()
|
||||
if effect not in EFFECT_OPTIONS:
|
||||
raise ValueError("mediaEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
|
||||
next_params["mediaEffect"] = effect
|
||||
|
||||
channel = str(next_params.get("mediaChannel", "stereo")).strip().lower()
|
||||
if channel not in CHANNEL_OPTIONS:
|
||||
raise ValueError("mediaChannel must be one of stereo, mono, left, right.")
|
||||
next_params["mediaChannel"] = channel
|
||||
|
||||
try:
|
||||
effect_value = float(next_params.get("mediaEffectValue", 50))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("mediaEffectValue must be a number.") from exc
|
||||
if not (0 <= effect_value <= 100):
|
||||
raise ValueError("mediaEffectValue must be between 0 and 100.")
|
||||
next_params["mediaEffectValue"] = round(effect_value, 1)
|
||||
|
||||
try:
|
||||
facing = float(next_params.get("facing", item.params.get("facing", 0)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("facing must be a number between 0 and 360.") from exc
|
||||
if not (0 <= facing <= 360):
|
||||
raise ValueError("facing must be between 0 and 360.")
|
||||
next_params["facing"] = int(round(facing))
|
||||
|
||||
try:
|
||||
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 20)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitRange must be an integer between 5 and 20.") from exc
|
||||
if not (5 <= emit_range <= 20):
|
||||
raise ValueError("emitRange must be between 5 and 20.")
|
||||
next_params["emitRange"] = emit_range
|
||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||
@@ -1 +1,3 @@
|
||||
"""Item type plugin package."""
|
||||
"""Item type package exposing plugin module surface."""
|
||||
|
||||
from .module import * # noqa: F401,F403
|
||||
|
||||
30
server/app/items/types/wheel/actions.py
Normal file
30
server/app/items/types/wheel/actions.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Wheel item use actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Callable
|
||||
|
||||
from ....item_types import ItemUseResult
|
||||
from ....models import WorldItem
|
||||
|
||||
|
||||
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||
"""Spin wheel and produce delayed landed value."""
|
||||
|
||||
spaces_raw = item.params.get("spaces", "")
|
||||
if isinstance(spaces_raw, str):
|
||||
spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()]
|
||||
elif isinstance(spaces_raw, list):
|
||||
spaces = [str(token).strip() for token in spaces_raw if str(token).strip()]
|
||||
else:
|
||||
spaces = []
|
||||
if not spaces:
|
||||
raise ValueError("wheel spaces must contain at least one comma-delimited value.")
|
||||
landed = str(random.choice(spaces))
|
||||
return ItemUseResult(
|
||||
self_message=f"You spin {item.title}.",
|
||||
others_message=f"{nickname} spins {item.title}.",
|
||||
delayed_self_message=landed,
|
||||
delayed_others_message=landed,
|
||||
)
|
||||
25
server/app/items/types/wheel/definition.py
Normal file
25
server/app/items/types/wheel/definition.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Wheel item static metadata and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABEL = "wheel"
|
||||
TOOLTIP = "Spin to win fabulous prizes."
|
||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "spaces")
|
||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||
USE_SOUND = "sounds/spin.ogg"
|
||||
EMIT_SOUND: str | None = None
|
||||
USE_COOLDOWN_MS = 4000
|
||||
EMIT_RANGE = 15
|
||||
DIRECTIONAL = False
|
||||
DEFAULT_TITLE = "wheel"
|
||||
DEFAULT_PARAMS: dict = {"spaces": "yes, no"}
|
||||
PARAM_KEYS: tuple[str, ...] = ("spaces",)
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"spaces": {
|
||||
"valueType": "text",
|
||||
"tooltip": "Comma-delimited list of wheel spaces. Example: yes, no, maybe.",
|
||||
"maxLength": 4000,
|
||||
},
|
||||
}
|
||||
@@ -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 ....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,
|
||||
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
|
||||
|
||||
__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",
|
||||
]
|
||||
|
||||
26
server/app/items/types/wheel/validator.py
Normal file
26
server/app/items/types/wheel/validator.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""Wheel item validation/normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....models import WorldItem
|
||||
from ...helpers import keep_only_known_params
|
||||
from .definition import PARAM_KEYS
|
||||
|
||||
|
||||
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
||||
"""Validate and normalize wheel params."""
|
||||
|
||||
spaces_raw = next_params.get("spaces", "")
|
||||
if not isinstance(spaces_raw, str):
|
||||
raise ValueError("spaces must be a comma-delimited string.")
|
||||
if len(spaces_raw) > 4000:
|
||||
raise ValueError("spaces must be 4000 characters or less.")
|
||||
spaces = [token.strip() for token in spaces_raw.split(",") if token.strip()]
|
||||
if not spaces:
|
||||
raise ValueError("spaces must include at least one value, separated by commas.")
|
||||
if len(spaces) > 100:
|
||||
raise ValueError("spaces supports up to 100 values.")
|
||||
if any(len(token) > 80 for token in spaces):
|
||||
raise ValueError("each space must be 80 chars or less.")
|
||||
next_params["spaces"] = ", ".join(spaces)
|
||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||
@@ -1 +1,3 @@
|
||||
"""Item type plugin package."""
|
||||
"""Item type package exposing plugin module surface."""
|
||||
|
||||
from .module import * # noqa: F401,F403
|
||||
|
||||
21
server/app/items/types/widget/actions.py
Normal file
21
server/app/items/types/widget/actions.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Widget item use actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from ....item_types import ItemUseResult
|
||||
from ....models import WorldItem
|
||||
from ...helpers import toggle_bool_param
|
||||
|
||||
|
||||
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||
"""Toggle enabled state for widget."""
|
||||
|
||||
next_enabled = toggle_bool_param(item.params, "enabled", default=True)
|
||||
state_text = "on" if next_enabled else "off"
|
||||
return ItemUseResult(
|
||||
self_message=f"You turn {state_text} {item.title}.",
|
||||
others_message=f"{nickname} turns {state_text} {item.title}.",
|
||||
updated_params={**item.params, "enabled": next_enabled},
|
||||
)
|
||||
102
server/app/items/types/widget/definition.py
Normal file
102
server/app/items/types/widget/definition.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Widget item static metadata and defaults."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
LABEL = "widget"
|
||||
TOOLTIP = "A basic item. Make it a beacon or whatever you want."
|
||||
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
||||
"title",
|
||||
"enabled",
|
||||
"directional",
|
||||
"facing",
|
||||
"emitRange",
|
||||
"emitVolume",
|
||||
"emitSoundSpeed",
|
||||
"emitSoundTempo",
|
||||
"emitEffect",
|
||||
"emitEffectValue",
|
||||
"useSound",
|
||||
"emitSound",
|
||||
)
|
||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||
USE_SOUND: str | None = None
|
||||
EMIT_SOUND: str | None = None
|
||||
USE_COOLDOWN_MS = 1000
|
||||
EMIT_RANGE = 15
|
||||
DIRECTIONAL = False
|
||||
DEFAULT_TITLE = "widget"
|
||||
DEFAULT_PARAMS: dict = {
|
||||
"enabled": True,
|
||||
"directional": False,
|
||||
"facing": 0,
|
||||
"emitRange": 15,
|
||||
"emitVolume": 100,
|
||||
"emitSoundSpeed": 50,
|
||||
"emitSoundTempo": 50,
|
||||
"emitEffect": "off",
|
||||
"emitEffectValue": 50,
|
||||
"useSound": "",
|
||||
"emitSound": "",
|
||||
}
|
||||
PARAM_KEYS: tuple[str, ...] = (
|
||||
"enabled",
|
||||
"directional",
|
||||
"facing",
|
||||
"emitRange",
|
||||
"emitVolume",
|
||||
"emitSoundSpeed",
|
||||
"emitSoundTempo",
|
||||
"emitEffect",
|
||||
"emitEffectValue",
|
||||
"useSound",
|
||||
"emitSound",
|
||||
)
|
||||
EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
|
||||
|
||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
||||
"enabled": {"valueType": "boolean", "tooltip": "Turns this widget on or off."},
|
||||
"directional": {"valueType": "boolean", "tooltip": "If on, emitted sound favors the facing direction."},
|
||||
"facing": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Facing direction in degrees used when directional is on.",
|
||||
"range": {"min": 0, "max": 360, "step": 1},
|
||||
"visibleWhen": {"directional": True},
|
||||
},
|
||||
"emitRange": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Maximum distance in squares for emitted sound.",
|
||||
"range": {"min": 1, "max": 20, "step": 1},
|
||||
},
|
||||
"emitVolume": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Emitted sound volume percent.",
|
||||
"range": {"min": 0, "max": 100, "step": 1},
|
||||
},
|
||||
"emitSoundSpeed": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Playback speed/pitch percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
|
||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||
},
|
||||
"emitSoundTempo": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Playback tempo percent for emitted sound. 50 is normal, 0 is half, 100 is double. Using speed and tempo together may sound weird.",
|
||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||
},
|
||||
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound."},
|
||||
"emitEffectValue": {
|
||||
"valueType": "number",
|
||||
"tooltip": "Amount for emit effect.",
|
||||
"range": {"min": 0, "max": 100, "step": 0.1},
|
||||
},
|
||||
"useSound": {
|
||||
"valueType": "sound",
|
||||
"tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL.",
|
||||
"maxLength": 2048,
|
||||
},
|
||||
"emitSound": {
|
||||
"valueType": "sound",
|
||||
"tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL.",
|
||||
"maxLength": 2048,
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
97
server/app/items/types/widget/validator.py
Normal file
97
server/app/items/types/widget/validator.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Widget item validation/normalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ....models import WorldItem
|
||||
from ...helpers import keep_only_known_params, parse_bool_like
|
||||
from .definition import EFFECT_OPTIONS, PARAM_KEYS
|
||||
|
||||
|
||||
def _normalize_sound_value(raw: object) -> str:
|
||||
"""Normalize sound value to empty/URL/or sounds-relative path."""
|
||||
|
||||
token = str(raw or "").strip()
|
||||
if not token:
|
||||
return ""
|
||||
lowered = token.lower()
|
||||
if lowered in {"none", "off"}:
|
||||
return ""
|
||||
if lowered.startswith(("http://", "https://", "data:", "blob:")):
|
||||
return token
|
||||
if token.startswith("/sounds/"):
|
||||
return token[1:]
|
||||
if token.startswith("sounds/"):
|
||||
return token
|
||||
if "/" not in token:
|
||||
return f"sounds/{token}"
|
||||
return token
|
||||
|
||||
|
||||
def validate_update(item: WorldItem, next_params: dict) -> dict:
|
||||
"""Validate and normalize widget params."""
|
||||
|
||||
enabled = parse_bool_like(next_params.get("enabled", item.params.get("enabled", True)), default=True)
|
||||
directional = parse_bool_like(next_params.get("directional", item.params.get("directional", False)), default=False)
|
||||
next_params["enabled"] = enabled
|
||||
next_params["directional"] = directional
|
||||
|
||||
try:
|
||||
facing = float(next_params.get("facing", item.params.get("facing", 0)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("facing must be a number between 0 and 360.") from exc
|
||||
if not (0 <= facing <= 360):
|
||||
raise ValueError("facing must be between 0 and 360.")
|
||||
next_params["facing"] = int(round(facing))
|
||||
|
||||
try:
|
||||
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 15)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitRange must be an integer between 1 and 20.") from exc
|
||||
if not (1 <= emit_range <= 20):
|
||||
raise ValueError("emitRange must be between 1 and 20.")
|
||||
next_params["emitRange"] = emit_range
|
||||
|
||||
try:
|
||||
emit_volume = int(next_params.get("emitVolume", item.params.get("emitVolume", 100)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitVolume must be an integer between 0 and 100.") from exc
|
||||
if not (0 <= emit_volume <= 100):
|
||||
raise ValueError("emitVolume must be between 0 and 100.")
|
||||
next_params["emitVolume"] = emit_volume
|
||||
|
||||
try:
|
||||
emit_speed = float(next_params.get("emitSoundSpeed", item.params.get("emitSoundSpeed", 50)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitSoundSpeed must be a number between 0 and 100.") from exc
|
||||
if not (0 <= emit_speed <= 100):
|
||||
raise ValueError("emitSoundSpeed must be between 0 and 100.")
|
||||
next_params["emitSoundSpeed"] = round(emit_speed, 1)
|
||||
|
||||
try:
|
||||
emit_tempo = float(next_params.get("emitSoundTempo", item.params.get("emitSoundTempo", 50)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitSoundTempo must be a number between 0 and 100.") from exc
|
||||
if not (0 <= emit_tempo <= 100):
|
||||
raise ValueError("emitSoundTempo must be between 0 and 100.")
|
||||
next_params["emitSoundTempo"] = round(emit_tempo, 1)
|
||||
|
||||
emit_effect = str(next_params.get("emitEffect", item.params.get("emitEffect", "off"))).strip().lower()
|
||||
if emit_effect not in EFFECT_OPTIONS:
|
||||
raise ValueError("emitEffect must be one of reverb, echo, flanger, high_pass, low_pass, off.")
|
||||
next_params["emitEffect"] = emit_effect
|
||||
|
||||
try:
|
||||
emit_effect_value = float(next_params.get("emitEffectValue", item.params.get("emitEffectValue", 50)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("emitEffectValue must be a number.") from exc
|
||||
if not (0 <= emit_effect_value <= 100):
|
||||
raise ValueError("emitEffectValue must be between 0 and 100.")
|
||||
next_params["emitEffectValue"] = round(emit_effect_value, 1)
|
||||
|
||||
next_params["useSound"] = _normalize_sound_value(next_params.get("useSound", item.params.get("useSound", "")))
|
||||
next_params["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", "")))
|
||||
if len(next_params["useSound"]) > 2048:
|
||||
raise ValueError("useSound must be 2048 characters or less.")
|
||||
if len(next_params["emitSound"]) > 2048:
|
||||
raise ValueError("emitSound must be 2048 characters or less.")
|
||||
return keep_only_known_params(next_params, PARAM_KEYS)
|
||||
Reference in New Issue
Block a user