Add widget item type with editable sound and spatial controls

This commit is contained in:
Jage9
2026-02-21 22:20:15 -05:00
parent 97caaef001
commit bb36a007e2
16 changed files with 309 additions and 27 deletions

View File

@@ -8,7 +8,7 @@ from typing import Literal, cast
from .items import clock, radio
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
ItemType = Literal["radio_station", "dice", "wheel", "clock"]
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget"]
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, ...]] = {

View File

@@ -33,7 +33,7 @@ class ItemService:
return int(time.time() * 1000)
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock"]) -> WorldItem:
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget"]) -> WorldItem:
"""Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type)

View File

@@ -7,7 +7,7 @@ from typing import Callable, Protocol
from ..item_types import ItemUseResult
from ..models import WorldItem
from . import clock, dice, radio, wheel
from . import clock, dice, radio, wheel, widget
class ItemModule(Protocol):
@@ -29,11 +29,12 @@ class ItemModule(Protocol):
use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel")
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel", "widget")
ITEM_MODULES: dict[str, ItemModule] = {
"clock": clock,
"dice": dice,
"radio_station": radio,
"wheel": wheel,
"widget": widget,
}

107
server/app/items/widget.py Normal file
View File

@@ -0,0 +1,107 @@
"""Widget item schema metadata and behavior."""
from __future__ import annotations
from typing import Callable
from ..item_types import ItemUseResult
from ..models import WorldItem
from .helpers import parse_bool_like, toggle_bool_param
LABEL = "widget"
TOOLTIP = "A basic item. Make it a beacon or whatever you want."
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "enabled", "directional", "facing", "emitRange", "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,
"useSound": "",
"emitSound": "",
}
PROPERTY_METADATA: dict[str, dict[str, object]] = {
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
"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": 0.1},
},
"emitRange": {
"valueType": "number",
"tooltip": "Maximum distance in squares for emitted sound.",
"range": {"min": 1, "max": 20, "step": 1},
},
"useSound": {"valueType": "sound", "tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL."},
"emitSound": {"valueType": "sound", "tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL."},
}
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"] = round(facing, 1)
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
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", "")))
return next_params
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},
)

View File

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

View File

@@ -117,6 +117,20 @@ class SignalingServer:
return "radio" if item.type == "radio_station" else item.type
@staticmethod
def _resolve_item_use_sound(item: WorldItem) -> str | None:
"""Resolve one-shot use sound, preferring per-item param override."""
param_sound = item.params.get("useSound")
if isinstance(param_sound, str):
token = param_sound.strip()
if token:
return token
return None
if isinstance(item.useSound, str) and item.useSound.strip():
return item.useSound.strip()
return None
def _is_in_bounds(self, x: int, y: int) -> bool:
"""Check whether a coordinate is inside server-authoritative world bounds."""
@@ -590,12 +604,13 @@ class SignalingServer:
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
exclude=client.websocket,
)
if item.useSound:
use_sound = self._resolve_item_use_sound(item)
if use_sound:
await self._broadcast(
ItemUseSoundPacket(
type="item_use_sound",
itemId=item.id,
sound=item.useSound,
sound=use_sound,
x=item.x,
y=item.y,
)

View File

@@ -242,3 +242,62 @@ async def test_failed_wheel_use_does_not_consume_cooldown(monkeypatch: pytest.Mo
item.params["spaces"] = "a,b,c"
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
assert send_payloads[-1].ok is True
@pytest.mark.asyncio
async def test_widget_update_and_use(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
item = server.item_service.default_item(client, "widget")
server.item_service.add_item(item)
send_payloads: list[object] = []
broadcast_payloads: list[object] = []
now_ms = 50_000
async def fake_send(websocket: ServerConnection, packet: object) -> None:
send_payloads.append(packet)
async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None:
broadcast_payloads.append(packet)
monkeypatch.setattr(server, "_send", fake_send)
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
monkeypatch.setattr(server.item_service, "now_ms", lambda: now_ms)
await server._handle_message(
client,
json.dumps(
{
"type": "item_update",
"itemId": item.id,
"params": {
"directional": True,
"facing": 123.4,
"emitRange": 7,
"useSound": "ping.ogg",
"emitSound": "https://example.com/ambient.ogg",
},
}
),
)
assert send_payloads[-1].ok is True
assert item.params.get("directional") is True
assert item.params.get("facing") == 123.4
assert item.params.get("emitRange") == 7
assert item.params.get("useSound") == "sounds/ping.ogg"
assert item.params.get("emitSound") == "https://example.com/ambient.ogg"
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
assert send_payloads[-1].ok is True
assert item.params.get("enabled") is False
assert any(getattr(packet, "type", "") == "item_use_sound" for packet in broadcast_payloads)
await server._handle_message(
client,
json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 21}}),
)
assert send_payloads[-1].ok is False
assert "emitrange must be between 1 and 20" in send_payloads[-1].message.lower()