From 297f1c0c1a96c7de8d57d2890c451965b39ad3e2 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Tue, 24 Feb 2026 20:55:02 -0500 Subject: [PATCH] Broadcast teleport landing sound to nearby users --- client/public/version.js | 2 +- client/src/main.ts | 1 + client/src/network/messageHandlers.ts | 7 ++++++ client/src/network/protocol.ts | 9 ++++++++ docs/protocol-notes.md | 3 +++ docs/runtime-flow.md | 1 + server/app/models.py | 12 ++++++++++ server/app/server.py | 14 ++++++++++++ server/tests/test_server_message_handling.py | 24 ++++++++++++++++++++ 9 files changed, 72 insertions(+), 1 deletion(-) diff --git a/client/public/version.js b/client/public/version.js index 8d5934e..2773708 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.25 R237"; +window.CHGRID_WEB_VERSION = "2026.02.25 R238"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 27009c4..30b9008 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1095,6 +1095,7 @@ function updateTeleport(): void { state.player.x = activeTeleport.targetX; state.player.y = activeTeleport.targetY; signaling.send({ type: 'update_position', x: activeTeleport.targetX, y: activeTeleport.targetY }); + signaling.send({ type: 'teleport_complete' }); activeTeleport = null; stopTeleportLoopAudio(); persistPlayerPosition(); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index e8f382a..fa75c4c 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -158,6 +158,13 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco break; } + case 'teleport_complete': { + if (deps.getAudioLayers().world) { + deps.playRemoteSpatialStepOrTeleport(deps.TELEPORT_SOUND_URL, message.x, message.y); + } + break; + } + case 'update_nickname': { const peer = deps.state.peers.get(message.id); if (peer) { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index f688fa8..dc8e4b2 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -103,6 +103,13 @@ export const updatePositionSchema = z.object({ y: z.number().int(), }); +export const teleportCompleteSchema = z.object({ + type: z.literal('teleport_complete'), + id: z.string(), + x: z.number().int(), + y: z.number().int(), +}); + export const updateNicknameSchema = z.object({ type: z.literal('update_nickname'), id: z.string(), @@ -199,6 +206,7 @@ export const incomingMessageSchema = z.discriminatedUnion('type', [ welcomeMessageSchema, signalMessageSchema, updatePositionSchema, + teleportCompleteSchema, updateNicknameSchema, userLeftSchema, chatMessageSchema, @@ -217,6 +225,7 @@ export type IncomingMessage = z.infer; export type OutgoingMessage = | { type: 'signal'; targetId: string; sdp?: RTCSessionDescriptionInit; ice?: RTCIceCandidateInit } | { type: 'update_position'; x: number; y: number } + | { type: 'teleport_complete' } | { type: 'update_nickname'; nickname: string } | { type: 'chat_message'; message: string } | { type: 'ping'; clientSentAt: number } diff --git a/docs/protocol-notes.md b/docs/protocol-notes.md index cd13949..f270697 100644 --- a/docs/protocol-notes.md +++ b/docs/protocol-notes.md @@ -11,6 +11,7 @@ This is a behavior guide for packet semantics beyond raw schemas. ## Client -> Server - `update_position`: client movement intent; server enforces world bounds and movement rate policy. +- `teleport_complete`: client signals teleport landing; server rebroadcasts spatial landing cue. - `update_nickname`: nickname change request (server enforces uniqueness). - `chat_message`: player chat. - `ping`: latency measurement. @@ -23,6 +24,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `welcome`: initial snapshot with users/items plus server UI/world metadata. - `signal`: forwarded WebRTC offer/answer/ICE. - `update_position`, `update_nickname`, `user_left`: presence updates. +- `teleport_complete`: peer teleport landing event with spatial coordinates. - `chat_message`: system and user chat stream. - `pong`: ping response. - `nickname_result`: accepted/rejected nickname result. @@ -41,6 +43,7 @@ This is a behavior guide for packet semantics beyond raw schemas. - `item_piano_status` carries machine-readable piano events (`use_mode_entered`, record/playback transitions). - `item_use_sound` contains absolute item world coordinates (`x`, `y`) and sound path. - For carried items, source coordinates resolve to the carrier's current position. +- `teleport_complete` contains absolute player world coordinates (`x`, `y`) at teleport landing. - `item_piano_note` contains: - `itemId`, `senderId`, `keyId`, `midi`, `on` - resolved `instrument`, `voiceMode`, `octave`, `attack`, `decay`, `release`, `brightness`, `emitRange` diff --git a/docs/runtime-flow.md b/docs/runtime-flow.md index 32d0d48..fa2703a 100644 --- a/docs/runtime-flow.md +++ b/docs/runtime-flow.md @@ -39,6 +39,7 @@ Core incoming message effects: - `signal`: WebRTC negotiation and ICE exchange. - `update_position`: update peer position; may play movement/teleport world sound. +- `teleport_complete`: play peer teleport landing sound at final tile. - `update_nickname`: update peer display name. - `chat_message`: append/readable status; optional system sound class. - `item_upsert`: replace item snapshot and resync item runtimes. diff --git a/server/app/models.py b/server/app/models.py index 2c15958..b06a601 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -25,6 +25,10 @@ class UpdatePositionPacket(BasePacket): y: int +class TeleportCompletePacket(BasePacket): + type: Literal["teleport_complete"] + + class UpdateNicknamePacket(BasePacket): type: Literal["update_nickname"] nickname: str = Field(min_length=1, max_length=32) @@ -91,6 +95,7 @@ class ItemUpdatePacket(BasePacket): ClientPacket = ( SignalPacket | UpdatePositionPacket + | TeleportCompletePacket | UpdateNicknamePacket | ChatMessagePacket | PingPacket @@ -135,6 +140,13 @@ class BroadcastPositionPacket(BasePacket): y: int +class BroadcastTeleportCompletePacket(BasePacket): + type: Literal["teleport_complete"] + id: str + x: int + y: int + + class BroadcastNicknamePacket(BasePacket): type: Literal["update_nickname"] id: str diff --git a/server/app/server.py b/server/app/server.py index 00acee2..74f926d 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -42,6 +42,7 @@ from .models import ( BroadcastChatMessagePacket, BroadcastNicknamePacket, BroadcastPositionPacket, + BroadcastTeleportCompletePacket, ChatMessagePacket, ClientPacket, ForwardSignalPacket, @@ -63,6 +64,7 @@ from .models import ( PingPacket, PongPacket, RemoteUser, + TeleportCompletePacket, UpdateNicknamePacket, UpdatePositionPacket, UserLeftPacket, @@ -879,6 +881,18 @@ class SignalingServer: await self._broadcast_item(carried) return + if isinstance(packet, TeleportCompletePacket): + await self._broadcast( + BroadcastTeleportCompletePacket( + type="teleport_complete", + id=client.id, + x=client.x, + y=client.y, + ), + exclude=client.websocket, + ) + return + if isinstance(packet, UpdateNicknamePacket): requested_nickname = packet.nickname.strip() if not requested_nickname: diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index 397f838..1194565 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -135,3 +135,27 @@ async def test_update_position_enforces_cumulative_budget_per_tick(monkeypatch: assert client.x == 7 assert client.y == 5 assert len(broadcast_payloads) == 2 + + +@pytest.mark.asyncio +async def test_teleport_complete_broadcasts_spatial_event(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None, grid_size=41) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=12, y=13) + server.clients[ws] = client + + broadcast_payloads: list[object] = [] + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_payloads.append(packet) + + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + + await server._handle_message(client, json.dumps({"type": "teleport_complete"})) + + assert len(broadcast_payloads) == 1 + packet = broadcast_payloads[0] + assert packet.type == "teleport_complete" + assert packet.id == "u1" + assert packet.x == 12 + assert packet.y == 13