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:
|
When adding a new item type:
|
||||||
|
|
||||||
1. Server item module
|
1. Server item package
|
||||||
- Add `server/app/items/types/<item_type>/module.py`.
|
- Add `server/app/items/types/<item_type>/` with:
|
||||||
- Define metadata/constants:
|
- `definition.py` for metadata/constants
|
||||||
- `LABEL`, `TOOLTIP`
|
- `validator.py` for `validate_update(item, next_params)`
|
||||||
- `EDITABLE_PROPERTIES`
|
- `actions.py` for `use_item(item, nickname, clock_formatter)`
|
||||||
- `CAPABILITIES`
|
- `module.py` as thin exported surface
|
||||||
- `USE_SOUND`, `EMIT_SOUND`
|
- `plugin.py` for registration
|
||||||
- `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)`
|
|
||||||
|
|
||||||
2. Server plugin file
|
2. Server plugin file
|
||||||
- Add `server/app/items/types/<item_type>/plugin.py` exporting:
|
- 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.
|
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
|
```py
|
||||||
from __future__ import annotations
|
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 typing import Callable
|
from .validator import validate_update
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Checklist Before Commit
|
## 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`.
|
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:
|
1. Server item package: add a new folder under `server/app/items/types/<item_type>/` with:
|
||||||
- defaults/capabilities
|
- `definition.py` (defaults/capabilities/metadata/options)
|
||||||
- property metadata/options
|
- `validator.py` (`validate_update`)
|
||||||
- `validate_update` and `use_item`
|
- `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:
|
2. Server plugin: add `server/app/items/types/<item_type>/plugin.py` exporting `ITEM_TYPE_PLUGIN` with:
|
||||||
- `type`
|
- `type`
|
||||||
- `order`
|
- `order`
|
||||||
|
|||||||
@@ -298,5 +298,5 @@ When adding a new item type:
|
|||||||
### Notes
|
### 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.
|
- 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.
|
- 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:
|
- Server type packages are now split into `definition.py` / `validator.py` / `actions.py` plus a thin `module.py` export surface.
|
||||||
- split server type modules into `definition.py`/`validator.py`/`actions.py` files per type if we want finer-grained plugin internals.
|
- 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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
from .actions import use_item
|
||||||
|
from .definition import (
|
||||||
from ....item_types import ItemUseResult
|
CAPABILITIES,
|
||||||
from ....models import WorldItem
|
DEFAULT_PARAMS,
|
||||||
from ...helpers import keep_only_known_params, parse_bool_like_or_none
|
DEFAULT_TIME_ZONE,
|
||||||
|
DEFAULT_TITLE,
|
||||||
LABEL = "clock"
|
DIRECTIONAL,
|
||||||
TOOLTIP = "It tells the time. What did you think it did?"
|
EDITABLE_PROPERTIES,
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "timeZone", "use24Hour")
|
EMIT_RANGE,
|
||||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
EMIT_SOUND,
|
||||||
USE_SOUND: str | None = None
|
LABEL,
|
||||||
EMIT_SOUND = "sounds/clock.ogg"
|
PROPERTY_METADATA,
|
||||||
USE_COOLDOWN_MS = 1000
|
TIME_ZONE_OPTIONS,
|
||||||
EMIT_RANGE = 10
|
TOOLTIP,
|
||||||
DIRECTIONAL = False
|
USE_COOLDOWN_MS,
|
||||||
DEFAULT_TITLE = "clock"
|
USE_SOUND,
|
||||||
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}
|
from .validator import validate_update
|
||||||
PARAM_KEYS: tuple[str, ...] = ("timeZone", "use24Hour")
|
|
||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
__all__ = [
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"LABEL",
|
||||||
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time."},
|
"TOOLTIP",
|
||||||
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
|
"EDITABLE_PROPERTIES",
|
||||||
}
|
"CAPABILITIES",
|
||||||
|
"USE_SOUND",
|
||||||
|
"EMIT_SOUND",
|
||||||
def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
"USE_COOLDOWN_MS",
|
||||||
"""Validate and normalize clock params."""
|
"EMIT_RANGE",
|
||||||
|
"DIRECTIONAL",
|
||||||
time_zone = str(next_params.get("timeZone", DEFAULT_TIME_ZONE)).strip()
|
"DEFAULT_TITLE",
|
||||||
if time_zone not in TIME_ZONE_OPTIONS:
|
"DEFAULT_TIME_ZONE",
|
||||||
raise ValueError(f"timeZone must be one of {', '.join(TIME_ZONE_OPTIONS)}.")
|
"TIME_ZONE_OPTIONS",
|
||||||
use_24_hour = parse_bool_like_or_none(next_params.get("use24Hour"))
|
"DEFAULT_PARAMS",
|
||||||
if use_24_hour is None:
|
"PROPERTY_METADATA",
|
||||||
raise ValueError("use24Hour must be on/off.")
|
"validate_update",
|
||||||
next_params["timeZone"] = time_zone
|
"use_item",
|
||||||
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}.",
|
|
||||||
)
|
|
||||||
|
|||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
from .actions import use_item
|
||||||
from typing import Callable
|
from .definition import (
|
||||||
|
CAPABILITIES,
|
||||||
from ....item_types import ItemUseResult
|
DEFAULT_PARAMS,
|
||||||
from ....models import WorldItem
|
DEFAULT_TITLE,
|
||||||
from ...helpers import keep_only_known_params
|
DIRECTIONAL,
|
||||||
|
EDITABLE_PROPERTIES,
|
||||||
LABEL = "dice"
|
EMIT_RANGE,
|
||||||
TOOLTIP = "Great for drinking games or boredom."
|
EMIT_SOUND,
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "sides", "number")
|
LABEL,
|
||||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
PROPERTY_METADATA,
|
||||||
USE_SOUND = "sounds/roll.ogg"
|
TOOLTIP,
|
||||||
EMIT_SOUND: str | None = None
|
USE_COOLDOWN_MS,
|
||||||
USE_COOLDOWN_MS = 1000
|
USE_SOUND,
|
||||||
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 .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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
from .actions import use_item
|
||||||
|
from .definition import (
|
||||||
from ....item_types import ItemUseResult
|
CAPABILITIES,
|
||||||
from ....models import WorldItem
|
DEFAULT_PARAMS,
|
||||||
from ...helpers import keep_only_known_params
|
DEFAULT_TITLE,
|
||||||
|
DIRECTIONAL,
|
||||||
LABEL = "piano"
|
EDITABLE_PROPERTIES,
|
||||||
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
|
EMIT_RANGE,
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
EMIT_SOUND,
|
||||||
"title",
|
INSTRUMENT_OPTIONS,
|
||||||
"instrument",
|
LABEL,
|
||||||
"voiceMode",
|
PROPERTY_METADATA,
|
||||||
"octave",
|
TOOLTIP,
|
||||||
"attack",
|
USE_COOLDOWN_MS,
|
||||||
"decay",
|
USE_SOUND,
|
||||||
"release",
|
VOICE_MODE_OPTIONS,
|
||||||
"brightness",
|
|
||||||
"emitRange",
|
|
||||||
)
|
)
|
||||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
from .validator import validate_update
|
||||||
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, ...] = (
|
__all__ = [
|
||||||
"piano",
|
"LABEL",
|
||||||
"electric_piano",
|
"TOOLTIP",
|
||||||
"guitar",
|
"EDITABLE_PROPERTIES",
|
||||||
"organ",
|
"CAPABILITIES",
|
||||||
"bass",
|
"USE_SOUND",
|
||||||
"violin",
|
"EMIT_SOUND",
|
||||||
"synth_lead",
|
"USE_COOLDOWN_MS",
|
||||||
"brass",
|
"EMIT_RANGE",
|
||||||
"nintendo",
|
"DIRECTIONAL",
|
||||||
"drum_kit",
|
"DEFAULT_TITLE",
|
||||||
)
|
"DEFAULT_PARAMS",
|
||||||
VOICE_MODE_OPTIONS: tuple[str, ...] = ("poly", "mono")
|
"PROPERTY_METADATA",
|
||||||
|
"INSTRUMENT_OPTIONS",
|
||||||
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] = {
|
"VOICE_MODE_OPTIONS",
|
||||||
"piano": (15, 45, 35, 55, "poly", 0),
|
"validate_update",
|
||||||
"electric_piano": (12, 40, 30, 62, "poly", 0),
|
"use_item",
|
||||||
"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}.",
|
|
||||||
)
|
|
||||||
|
|||||||
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
from .actions import use_item
|
||||||
|
from .definition import (
|
||||||
from ....item_types import ItemUseResult
|
CAPABILITIES,
|
||||||
from ....models import WorldItem
|
CHANNEL_OPTIONS,
|
||||||
from ...helpers import keep_only_known_params, toggle_bool_param
|
DEFAULT_PARAMS,
|
||||||
|
DEFAULT_TITLE,
|
||||||
LABEL = "radio"
|
DIRECTIONAL,
|
||||||
TOOLTIP = "Can play stations from the Internet. Tune multiple to the same station and they will sync up."
|
EDITABLE_PROPERTIES,
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
EFFECT_OPTIONS,
|
||||||
"title",
|
EMIT_RANGE,
|
||||||
"streamUrl",
|
EMIT_SOUND,
|
||||||
"enabled",
|
LABEL,
|
||||||
"mediaVolume",
|
PROPERTY_METADATA,
|
||||||
"mediaChannel",
|
TOOLTIP,
|
||||||
"mediaEffect",
|
USE_COOLDOWN_MS,
|
||||||
"mediaEffectValue",
|
USE_SOUND,
|
||||||
"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 .validator import validate_update
|
||||||
|
|
||||||
CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right")
|
__all__ = [
|
||||||
EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
|
"LABEL",
|
||||||
|
"TOOLTIP",
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
"EDITABLE_PROPERTIES",
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"CAPABILITIES",
|
||||||
"streamUrl": {"valueType": "text", "tooltip": "Audio stream URL used by this radio.", "maxLength": 2048},
|
"USE_SOUND",
|
||||||
"enabled": {"valueType": "boolean", "tooltip": "Turns playback on or off for this radio."},
|
"EMIT_SOUND",
|
||||||
"mediaVolume": {
|
"USE_COOLDOWN_MS",
|
||||||
"valueType": "number",
|
"EMIT_RANGE",
|
||||||
"tooltip": "Playback media volume percent for this radio.",
|
"DIRECTIONAL",
|
||||||
"range": {"min": 0, "max": 100, "step": 1},
|
"DEFAULT_TITLE",
|
||||||
},
|
"DEFAULT_PARAMS",
|
||||||
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered."},
|
"PROPERTY_METADATA",
|
||||||
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect."},
|
"CHANNEL_OPTIONS",
|
||||||
"mediaEffectValue": {
|
"EFFECT_OPTIONS",
|
||||||
"valueType": "number",
|
"validate_update",
|
||||||
"tooltip": "Amount for the selected effect.",
|
"use_item",
|
||||||
"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},
|
|
||||||
)
|
|
||||||
|
|||||||
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import random
|
from .actions import use_item
|
||||||
from typing import Callable
|
from .definition import (
|
||||||
|
CAPABILITIES,
|
||||||
from ....item_types import ItemUseResult
|
DEFAULT_PARAMS,
|
||||||
from ....models import WorldItem
|
DEFAULT_TITLE,
|
||||||
from ...helpers import keep_only_known_params
|
DIRECTIONAL,
|
||||||
|
EDITABLE_PROPERTIES,
|
||||||
LABEL = "wheel"
|
EMIT_RANGE,
|
||||||
TOOLTIP = "Spin to win fabulous prizes."
|
EMIT_SOUND,
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "spaces")
|
LABEL,
|
||||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
PROPERTY_METADATA,
|
||||||
USE_SOUND = "sounds/spin.ogg"
|
TOOLTIP,
|
||||||
EMIT_SOUND: str | None = None
|
USE_COOLDOWN_MS,
|
||||||
USE_COOLDOWN_MS = 4000
|
USE_SOUND,
|
||||||
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 .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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Callable
|
from .actions import use_item
|
||||||
|
from .definition import (
|
||||||
from ....item_types import ItemUseResult
|
CAPABILITIES,
|
||||||
from ....models import WorldItem
|
DEFAULT_PARAMS,
|
||||||
from ...helpers import keep_only_known_params, parse_bool_like, toggle_bool_param
|
DEFAULT_TITLE,
|
||||||
|
DIRECTIONAL,
|
||||||
LABEL = "widget"
|
EDITABLE_PROPERTIES,
|
||||||
TOOLTIP = "A basic item. Make it a beacon or whatever you want."
|
EFFECT_OPTIONS,
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = (
|
EMIT_RANGE,
|
||||||
"title",
|
EMIT_SOUND,
|
||||||
"enabled",
|
LABEL,
|
||||||
"directional",
|
PROPERTY_METADATA,
|
||||||
"facing",
|
TOOLTIP,
|
||||||
"emitRange",
|
USE_COOLDOWN_MS,
|
||||||
"emitVolume",
|
USE_SOUND,
|
||||||
"emitSoundSpeed",
|
|
||||||
"emitSoundTempo",
|
|
||||||
"emitEffect",
|
|
||||||
"emitEffectValue",
|
|
||||||
"useSound",
|
|
||||||
"emitSound",
|
|
||||||
)
|
)
|
||||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
from .validator import validate_update
|
||||||
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]] = {
|
__all__ = [
|
||||||
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item.", "maxLength": 80},
|
"LABEL",
|
||||||
"enabled": {"valueType": "boolean", "tooltip": "Turns this widget on or off."},
|
"TOOLTIP",
|
||||||
"directional": {"valueType": "boolean", "tooltip": "If on, emitted sound favors the facing direction."},
|
"EDITABLE_PROPERTIES",
|
||||||
"facing": {
|
"CAPABILITIES",
|
||||||
"valueType": "number",
|
"USE_SOUND",
|
||||||
"tooltip": "Facing direction in degrees used when directional is on.",
|
"EMIT_SOUND",
|
||||||
"range": {"min": 0, "max": 360, "step": 1},
|
"USE_COOLDOWN_MS",
|
||||||
"visibleWhen": {"directional": True},
|
"EMIT_RANGE",
|
||||||
},
|
"DIRECTIONAL",
|
||||||
"emitRange": {
|
"DEFAULT_TITLE",
|
||||||
"valueType": "number",
|
"DEFAULT_PARAMS",
|
||||||
"tooltip": "Maximum distance in squares for emitted sound.",
|
"PROPERTY_METADATA",
|
||||||
"range": {"min": 1, "max": 20, "step": 1},
|
"EFFECT_OPTIONS",
|
||||||
},
|
"validate_update",
|
||||||
"emitVolume": {
|
"use_item",
|
||||||
"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},
|
|
||||||
)
|
|
||||||
|
|||||||
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