refactor: complete server-first item schema wiring and plugin contract checks

This commit is contained in:
Jage9
2026-02-24 18:48:08 -05:00
parent 7776676e2d
commit fcb5e85b13
20 changed files with 132 additions and 69 deletions

View File

@@ -3,11 +3,11 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, cast
from typing import TypeAlias, cast
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
ItemType: TypeAlias = str
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
@@ -75,15 +75,6 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
for item_type in ITEM_TYPE_SEQUENCE
}
ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
"mediaEffect": RADIO_EFFECT_OPTIONS,
"emitEffect": RADIO_EFFECT_OPTIONS,
"mediaChannel": RADIO_CHANNEL_OPTIONS,
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
"instrument": PIANO_INSTRUMENT_OPTIONS,
"voiceMode": PIANO_VOICE_MODE_OPTIONS,
}
ITEM_TYPE_TOOLTIPS: dict[ItemType, str] = {
item_type: ITEM_MODULES[item_type].TOOLTIP for item_type in ITEM_TYPE_SEQUENCE
}
@@ -125,6 +116,12 @@ def get_item_definition(item_type: ItemType) -> ItemDefinition:
return ITEM_DEFINITIONS[item_type]
def is_known_item_type(item_type: str) -> bool:
"""Return whether a string item type id exists in discovered plugins."""
return item_type in ITEM_DEFINITIONS
def get_item_use_cooldown_ms(item_type: ItemType) -> int:
"""Return validated global use cooldown in milliseconds for an item type."""

View File

@@ -8,8 +8,6 @@ import time
import uuid
from copy import deepcopy
from pathlib import Path
from typing import Literal
from .client import ClientConnection
from .item_catalog import get_item_definition
from .models import PersistedWorldItem, WorldItem
@@ -36,7 +34,7 @@ class ItemService:
return int(time.time() * 1000)
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem:
def default_item(self, client: ClientConnection, item_type: str) -> WorldItem:
"""Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type)

View File

@@ -60,6 +60,6 @@ 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."},
"timeZone": {"valueType": "list", "tooltip": "Timezone used when the clock speaks time.", "options": list(TIME_ZONE_OPTIONS)},
"use24Hour": {"valueType": "boolean", "tooltip": "Use 24 hour format instead of AM/PM."},
}

View File

@@ -64,8 +64,8 @@ DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int, str, int]] =
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."},
"instrument": {"valueType": "list", "tooltip": "Instrument voice used when playing this piano.", "options": list(INSTRUMENT_OPTIONS)},
"voiceMode": {"valueType": "list", "tooltip": "Mono plays one note at a time; poly allows chords.", "options": list(VOICE_MODE_OPTIONS)},
"octave": {
"valueType": "number",
"tooltip": "Shifts played notes in octaves. -1 is one octave down.",

View File

@@ -55,8 +55,8 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
"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."},
"mediaChannel": {"valueType": "list", "tooltip": "Select how the station audio channels are rendered.", "options": list(CHANNEL_OPTIONS)},
"mediaEffect": {"valueType": "list", "tooltip": "Select the active radio effect.", "options": list(EFFECT_OPTIONS)},
"mediaEffectValue": {
"valueType": "number",
"tooltip": "Amount for the selected effect.",

View File

@@ -83,7 +83,7 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
"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."},
"emitEffect": {"valueType": "list", "tooltip": "Effect applied to emitted sound.", "options": list(EFFECT_OPTIONS)},
"emitEffectValue": {
"valueType": "number",
"tooltip": "Amount for emit effect.",

View File

@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
class ItemAddPacket(BasePacket):
type: Literal["item_add"]
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
itemType: str = Field(min_length=1)
class ItemPickupPacket(BasePacket):
@@ -173,7 +173,7 @@ class NicknameResultPacket(BasePacket):
class WorldItem(BaseModel):
id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
type: str = Field(min_length=1)
title: str
x: int
y: int
@@ -191,7 +191,7 @@ class WorldItem(BaseModel):
class PersistedWorldItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
type: str = Field(min_length=1)
title: str
x: int
y: int

View File

@@ -25,14 +25,15 @@ from .config import load_config
from .item_catalog import (
CLOCK_DEFAULT_TIME_ZONE,
CLOCK_TIME_ZONE_OPTIONS,
ITEM_PROPERTY_OPTIONS,
ITEM_TYPE_EDITABLE_PROPERTIES,
ITEM_TYPE_LABELS,
ITEM_TYPE_PROPERTY_METADATA,
ITEM_TYPE_SEQUENCE,
ITEM_TYPE_TOOLTIPS,
get_item_definition,
get_item_global_properties,
get_item_use_cooldown_ms,
is_known_item_type,
)
from .item_type_handlers import get_item_type_handler
from .item_service import ItemService
@@ -708,18 +709,13 @@ class SignalingServer:
item_types: list[dict] = []
for item_type in ITEM_TYPE_SEQUENCE:
editable = list(ITEM_TYPE_EDITABLE_PROPERTIES.get(item_type, ("title",)))
property_options: dict[str, list[str]] = {}
for key in editable:
options = ITEM_PROPERTY_OPTIONS.get(key)
if options:
property_options[key] = list(options)
item_types.append(
{
"type": item_type,
"label": ITEM_TYPE_LABELS.get(item_type, item_type),
"tooltip": ITEM_TYPE_TOOLTIPS.get(item_type),
"capabilities": list(get_item_definition(item_type).capabilities),
"editableProperties": editable,
"propertyOptions": property_options,
"propertyMetadata": ITEM_TYPE_PROPERTY_METADATA.get(item_type, {}),
"globalProperties": get_item_global_properties(item_type),
}
@@ -897,6 +893,9 @@ class SignalingServer:
return
if isinstance(packet, ItemAddPacket):
if not is_known_item_type(packet.itemType):
await self._send_item_result(client, False, "add", "Unknown item type.")
return
item = self.item_service.default_item(client, packet.itemType)
self.item_service.add_item(item)
await self._broadcast_item(item)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from pathlib import Path
from app.items.registry import ITEM_PLUGINS
def test_item_plugins_expose_expected_contract() -> None:
for plugin in ITEM_PLUGINS:
module = plugin.module
assert isinstance(plugin.type, str) and plugin.type
assert isinstance(plugin.order, int)
assert callable(getattr(module, "validate_update", None))
assert callable(getattr(module, "use_item", None))
assert isinstance(getattr(module, "LABEL", ""), str)
assert isinstance(getattr(module, "TOOLTIP", ""), str)
assert isinstance(getattr(module, "EDITABLE_PROPERTIES", ()), tuple)
assert isinstance(getattr(module, "CAPABILITIES", ()), tuple)
assert isinstance(getattr(module, "DEFAULT_PARAMS", {}), dict)
assert isinstance(getattr(module, "PROPERTY_METADATA", {}), dict)
def test_item_plugin_folders_have_required_files() -> None:
base_dir = Path(__file__).resolve().parents[1] / "app" / "items" / "types"
for plugin in ITEM_PLUGINS:
type_dir = base_dir / plugin.type
assert type_dir.is_dir()
assert (type_dir / "definition.py").is_file()
assert (type_dir / "validator.py").is_file()
assert (type_dir / "actions.py").is_file()
assert (type_dir / "module.py").is_file()
assert (type_dir / "plugin.py").is_file()

View File

@@ -33,15 +33,16 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
assert isinstance(entry.get("type"), str)
assert isinstance(entry.get("label"), str)
assert isinstance(entry.get("editableProperties"), list)
assert isinstance(entry.get("capabilities"), list)
assert isinstance(entry.get("propertyMetadata"), dict)
assert isinstance(entry.get("propertyOptions"), dict)
assert isinstance(entry.get("globalProperties"), dict)
editable_properties = entry["editableProperties"]
capabilities = entry["capabilities"]
property_metadata = entry["propertyMetadata"]
property_options = entry["propertyOptions"]
global_properties = entry["globalProperties"]
assert capabilities
assert required_global_keys.issubset(set(global_properties.keys()))
for property_key in editable_properties:
if property_key == "title":
@@ -50,7 +51,7 @@ def test_ui_definitions_are_complete_for_all_item_types() -> None:
metadata = property_metadata[property_key]
assert isinstance(metadata, dict)
if metadata.get("valueType") == "list":
options = property_options.get(property_key)
options = metadata.get("options")
assert isinstance(options, list)
assert options

View File

@@ -83,3 +83,24 @@ async def test_broadcast_fanout_is_concurrent(monkeypatch: pytest.MonkeyPatch) -
assert ws1 in send_started_at
assert ws2 in send_started_at
assert abs(send_started_at[ws1] - send_started_at[ws2]) < 0.02
@pytest.mark.asyncio
async def test_item_add_rejects_unknown_type(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)
ws = _fake_ws()
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
server.clients[ws] = client
send_payloads: list[object] = []
async def fake_send(websocket: ServerConnection, packet: object) -> None:
send_payloads.append(packet)
monkeypatch.setattr(server, "_send", fake_send)
await server._handle_message(client, json.dumps({"type": "item_add", "itemType": "not_a_type"}))
assert send_payloads
assert send_payloads[-1].ok is False
assert "unknown item type" in send_payloads[-1].message.lower()