Fix carried use-sound origin and centralize sound normalization

This commit is contained in:
Jage9
2026-02-24 20:34:48 -05:00
parent fa65d7bd0d
commit 686d065bf9
10 changed files with 267 additions and 34 deletions

View File

@@ -0,0 +1,44 @@
"""Shared normalization helpers for item sound/media URL parameters."""
from __future__ import annotations
def normalize_sound_reference(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 normalize_media_reference(raw: object) -> str:
"""Normalize media URL-like value while preserving path/query format."""
token = str(raw or "").strip()
if not token:
return ""
lowered = token.lower()
if lowered in {"none", "off"}:
return ""
return token
def enforce_max_length(value: str, *, max_length: int, field_name: str) -> str:
"""Enforce max character length for normalized string fields."""
if len(value) > max_length:
raise ValueError(f"{field_name} must be {max_length} characters or less.")
return value

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from ....models import WorldItem
from ...sound_policy import enforce_max_length, normalize_media_reference
from ...helpers import keep_only_known_params
from .definition import CHANNEL_OPTIONS, EFFECT_OPTIONS, PARAM_KEYS
@@ -10,10 +11,11 @@ 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
next_params["streamUrl"] = enforce_max_length(
normalize_media_reference(next_params.get("streamUrl", "")),
max_length=2048,
field_name="streamUrl",
)
enabled_value = next_params.get("enabled", True)
if isinstance(enabled_value, bool):

View File

@@ -3,30 +3,11 @@
from __future__ import annotations
from ....models import WorldItem
from ...sound_policy import enforce_max_length, normalize_sound_reference
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."""
@@ -88,10 +69,14 @@ def validate_update(item: WorldItem, next_params: dict) -> dict:
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.")
next_params["useSound"] = enforce_max_length(
normalize_sound_reference(next_params.get("useSound", item.params.get("useSound", ""))),
max_length=2048,
field_name="useSound",
)
next_params["emitSound"] = enforce_max_length(
normalize_sound_reference(next_params.get("emitSound", item.params.get("emitSound", ""))),
max_length=2048,
field_name="emitSound",
)
return keep_only_known_params(next_params, PARAM_KEYS)

View File

@@ -209,6 +209,15 @@ class SignalingServer:
return item.useSound.strip()
return None
def _get_item_sound_source_position(self, item: WorldItem) -> tuple[int, int]:
"""Resolve source position for item-emitted one-shot sounds."""
if item.carrierId:
carrier = self._get_client_by_id(item.carrierId)
if carrier is not None:
return carrier.x, carrier.y
return item.x, item.y
def _get_client_by_id(self, client_id: str) -> ClientConnection | None:
"""Resolve one connected client by id."""
@@ -1141,13 +1150,14 @@ class SignalingServer:
)
use_sound = self._resolve_item_use_sound(item)
if use_sound:
sound_x, sound_y = self._get_item_sound_source_position(item)
await self._broadcast(
ItemUseSoundPacket(
type="item_use_sound",
itemId=item.id,
sound=use_sound,
x=item.x,
y=item.y,
x=sound_x,
y=sound_y,
)
)
if item.type == "piano":

View File

@@ -399,6 +399,44 @@ async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
assert "emitsoundtempo must be between 0 and 100" in send_payloads[-1].message.lower()
@pytest.mark.asyncio
async def test_carried_item_use_sound_uses_carrier_position(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")
item.params["useSound"] = "sounds/test.ogg"
item.carrierId = client.id
# Keep stale coordinates to verify carrier position is used for use-sound broadcasts.
item.x = 1
item.y = 1
server.item_service.add_item(item)
client.x = 9
client.y = 10
send_payloads: list[object] = []
broadcast_payloads: list[object] = []
now_ms = 60_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_use", "itemId": item.id}))
assert send_payloads[-1].ok is True
sound_packets = [packet for packet in broadcast_payloads if getattr(packet, "type", "") == "item_use_sound"]
assert sound_packets
assert sound_packets[-1].x == 9
assert sound_packets[-1].y == 10
@pytest.mark.asyncio
async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
server = SignalingServer("127.0.0.1", 8765, None, None)