Add piano item type with realtime play mode and remote notes

This commit is contained in:
Jage9
2026-02-22 23:42:17 -05:00
parent 81c6af6399
commit 1319c044dd
23 changed files with 1014 additions and 23 deletions

View File

@@ -5,10 +5,10 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, cast
from .items import clock, radio
from .items import clock, piano, radio
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget"]
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
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, ...]] = {
@@ -19,6 +19,7 @@ CLOCK_DEFAULT_TIME_ZONE = clock.DEFAULT_TIME_ZONE
CLOCK_TIME_ZONE_OPTIONS = clock.TIME_ZONE_OPTIONS
RADIO_EFFECT_OPTIONS = radio.EFFECT_OPTIONS
RADIO_CHANNEL_OPTIONS = radio.CHANNEL_OPTIONS
PIANO_INSTRUMENT_OPTIONS = piano.INSTRUMENT_OPTIONS
@dataclass(frozen=True)
@@ -79,6 +80,7 @@ ITEM_PROPERTY_OPTIONS: dict[str, tuple[str, ...]] = {
"emitEffect": RADIO_EFFECT_OPTIONS,
"mediaChannel": RADIO_CHANNEL_OPTIONS,
"timeZone": CLOCK_TIME_ZONE_OPTIONS,
"instrument": PIANO_INSTRUMENT_OPTIONS,
}
ITEM_TYPE_TOOLTIPS: dict[ItemType, 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", "widget"]) -> WorldItem:
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]) -> WorldItem:
"""Create a new server-authoritative item at the caller's position."""
item_def = get_item_definition(item_type)

100
server/app/items/piano.py Normal file
View File

@@ -0,0 +1,100 @@
"""Piano item schema metadata and behavior."""
from __future__ import annotations
from typing import Callable
from ..item_types import ItemUseResult
from ..models import WorldItem
LABEL = "piano"
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "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",
"attack": 15,
"decay": 45,
"emitRange": 15,
}
INSTRUMENT_OPTIONS: tuple[str, ...] = (
"piano",
"electric_piano",
"guitar",
"organ",
"bass",
"violin",
"synth_lead",
"drum_kit",
)
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."},
"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},
},
"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."""
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)}.")
next_params["instrument"] = instrument
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.")
next_params["attack"] = attack
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.")
next_params["decay"] = decay
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
return next_params
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}.",
)

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, widget
from . import clock, dice, piano, 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", "widget")
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "piano", "radio_station", "wheel", "widget")
ITEM_MODULES: dict[str, ItemModule] = {
"clock": clock,
"dice": dice,
"piano": piano,
"radio_station": radio,
"wheel": wheel,
"widget": widget,

View File

@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
class ItemAddPacket(BasePacket):
type: Literal["item_add"]
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget"]
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
class ItemPickupPacket(BasePacket):
@@ -67,6 +67,14 @@ class ItemUsePacket(BasePacket):
itemId: str
class ItemPianoNotePacket(BasePacket):
type: Literal["item_piano_note"]
itemId: str
keyId: str = Field(min_length=1, max_length=32)
midi: int = Field(ge=0, le=127)
on: bool
class ItemUpdatePacket(BasePacket):
type: Literal["item_update"]
itemId: str
@@ -85,6 +93,7 @@ ClientPacket = (
| ItemDropPacket
| ItemDeletePacket
| ItemUsePacket
| ItemPianoNotePacket
| ItemUpdatePacket
)
@@ -157,7 +166,7 @@ class NicknameResultPacket(BasePacket):
class WorldItem(BaseModel):
id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
title: str
x: int
y: int
@@ -175,7 +184,7 @@ class WorldItem(BaseModel):
class PersistedWorldItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str
type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
type: Literal["radio_station", "dice", "wheel", "clock", "widget", "piano"]
title: str
x: int
y: int
@@ -211,3 +220,18 @@ class ItemUseSoundPacket(BasePacket):
sound: str
x: int
y: int
class ItemPianoNoteBroadcastPacket(BasePacket):
type: Literal["item_piano_note"]
itemId: str
senderId: str
keyId: str
midi: int
on: bool
instrument: str
attack: int
decay: int
x: int
y: int
emitRange: int

View File

@@ -46,6 +46,8 @@ from .models import (
ItemAddPacket,
ItemDeletePacket,
ItemDropPacket,
ItemPianoNoteBroadcastPacket,
ItemPianoNotePacket,
ItemPickupPacket,
ItemRemovePacket,
ItemUpdatePacket,
@@ -656,6 +658,39 @@ class SignalingServer:
)
return
if isinstance(packet, ItemPianoNotePacket):
item = self.items.get(packet.itemId)
if not item or item.type != "piano":
return
if item.carrierId not in (None, client.id):
return
if item.carrierId is None and (item.x != client.x or item.y != client.y):
return
instrument = str(item.params.get("instrument", "piano")).strip().lower()
attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15
decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45
emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15
source_x = client.x if item.carrierId == client.id else item.x
source_y = client.y if item.carrierId == client.id else item.y
await self._broadcast(
ItemPianoNoteBroadcastPacket(
type="item_piano_note",
itemId=item.id,
senderId=client.id,
keyId=packet.keyId,
midi=packet.midi,
on=packet.on,
instrument=instrument,
attack=max(0, min(100, attack)),
decay=max(0, min(100, decay)),
x=source_x,
y=source_y,
emitRange=max(5, min(20, emit_range)),
),
exclude=client.websocket,
)
return
if isinstance(packet, ItemUpdatePacket):
item = self.items.get(packet.itemId)
if not item:

View File

@@ -339,3 +339,102 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
)
assert send_payloads[-1].ok is False
assert "emitsoundtempo must be between 0 and 100" in send_payloads[-1].message.lower()
@pytest.mark.asyncio
async def test_piano_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, "piano")
server.item_service.add_item(item)
send_payloads: list[object] = []
broadcast_payloads: list[object] = []
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)
await server._handle_message(
client,
json.dumps(
{
"type": "item_update",
"itemId": item.id,
"params": {
"instrument": "drum_kit",
"attack": 22,
"decay": 67,
"emitRange": 12,
},
}
),
)
assert send_payloads[-1].ok is True
assert item.params.get("instrument") == "drum_kit"
assert item.params.get("attack") == 22
assert item.params.get("decay") == 67
assert item.params.get("emitRange") == 12
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
assert send_payloads[-1].ok is True
assert "begin playing" in send_payloads[-1].message.lower()
assert not 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": {"instrument": "banjo"}}),
)
assert send_payloads[-1].ok is False
assert "instrument must be one of" in send_payloads[-1].message.lower()
@pytest.mark.asyncio
async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)
ws_sender = _fake_ws()
sender = ClientConnection(websocket=ws_sender, id="u1", nickname="tester", x=5, y=6)
ws_other = _fake_ws()
other = ClientConnection(websocket=ws_other, id="u2", nickname="listener", x=7, y=6)
server.clients[ws_sender] = sender
server.clients[ws_other] = other
item = server.item_service.default_item(sender, "piano")
item.params["instrument"] = "organ"
item.params["attack"] = 20
item.params["decay"] = 60
item.params["emitRange"] = 12
server.item_service.add_item(item)
send_payloads: list[object] = []
broadcast_payloads: list[object] = []
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)
await server._handle_message(
sender,
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyA", "midi": 60, "on": True}),
)
assert not send_payloads
assert broadcast_payloads
packet = broadcast_payloads[-1]
assert getattr(packet, "type", "") == "item_piano_note"
assert getattr(packet, "itemId", "") == item.id
assert getattr(packet, "instrument", "") == "organ"
assert getattr(packet, "attack", -1) == 20
assert getattr(packet, "decay", -1) == 60
assert getattr(packet, "emitRange", -1) == 12

View File

@@ -16,3 +16,9 @@ def test_unknown_type_rejected() -> None:
except ValidationError:
return
assert False, "validation should fail"
def test_item_add_accepts_piano_type() -> None:
adapter = TypeAdapter(ClientPacket)
packet = adapter.validate_python({"type": "item_add", "itemType": "piano"})
assert packet.type == "item_add"