Add piano item type with realtime play mode and remote notes
This commit is contained in:
@@ -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] = {
|
||||
|
||||
@@ -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
100
server/app/items/piano.py
Normal 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}.",
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user