From 464d39f78b9fee144e0c07e54842ed6b0e507079 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Fri, 27 Feb 2026 04:33:54 -0500 Subject: [PATCH] Add server chat slash commands for me and uptime --- client/public/help.json | 4 + client/public/version.js | 2 +- client/src/main.ts | 2 + client/src/network/messageHandlers.ts | 6 +- client/src/network/protocol.ts | 1 + docs/controls.md | 3 + server/app/models.py | 1 + server/app/server.py | 78 ++++++++++++++++++ server/tests/test_server_message_handling.py | 84 ++++++++++++++++++++ 9 files changed, 179 insertions(+), 2 deletions(-) diff --git a/client/public/help.json b/client/public/help.json index c4e754c..c2e456a 100644 --- a/client/public/help.json +++ b/client/public/help.json @@ -40,6 +40,10 @@ "keys": "Slash", "description": "Start chat" }, + { + "keys": "Slash commands", + "description": "In chat, use /me for action text or /up for server uptime" + }, { "keys": "Comma / Period", "description": "Previous/next message" diff --git a/client/public/version.js b/client/public/version.js index 7ab90e5..2df5228 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.27 R292"; +window.CHGRID_WEB_VERSION = "2026.02.27 R293"; // 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 4d9a06c..9d3d845 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -238,6 +238,7 @@ const SYSTEM_SOUND_URLS = { logout: withBase('sounds/logout.ogg'), notify: withBase('sounds/notify.ogg'), } as const; +const ACTION_SOUND_URL = SYSTEM_SOUND_URLS.notify; const FOOTSTEP_SOUND_URLS = Array.from({ length: 11 }, (_, index) => withBase(`sounds/step-${index + 1}.ogg`)); const FOOTSTEP_GAIN = 0.7; const TELEPORT_START_SOUND_URL = withBase('sounds/teleport_start.ogg'); @@ -1820,6 +1821,7 @@ const onAppMessage = createOnMessageHandler({ getAudioLayers: () => audioLayers, pushChatMessage, classifySystemMessageSound, + ACTION_SOUND_URL, SYSTEM_SOUND_URLS, playSample: (url, gain = 1) => { void audio.playSample(url, gain); diff --git a/client/src/network/messageHandlers.ts b/client/src/network/messageHandlers.ts index 7a85a09..38f6d8c 100644 --- a/client/src/network/messageHandlers.ts +++ b/client/src/network/messageHandlers.ts @@ -54,6 +54,7 @@ type MessageHandlerDeps = { getAudioLayers: () => { world: boolean; item: boolean }; pushChatMessage: (message: string) => void; classifySystemMessageSound: (message: string) => 'logon' | 'logout' | 'notify' | null; + ACTION_SOUND_URL: string; SYSTEM_SOUND_URLS: { logon: string; logout: string; notify: string }; playSample: (url: string, gain?: number) => void; updateStatus: (message: string) => void; @@ -225,7 +226,10 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco } case 'chat_message': { - if (message.system) { + if (message.action) { + deps.pushChatMessage(message.message); + deps.playSample(deps.ACTION_SOUND_URL, 1); + } else if (message.system) { deps.pushChatMessage(message.message); const sound = deps.classifySystemMessageSound(message.message); if (sound) { diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index f70135e..b5cb7e4 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -185,6 +185,7 @@ export const chatMessageSchema = z.object({ senderId: z.string().optional(), senderNickname: z.string().optional(), system: z.boolean().optional(), + action: z.boolean().optional(), }); export const pongSchema = z.object({ diff --git a/docs/controls.md b/docs/controls.md index 186f89c..9bdf8de 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -16,6 +16,9 @@ This document is the authoritative keymap for the client. - `U`: Speak connected users - `N`: Edit nickname - `/`: Start chat +- In chat, commands are supported when `/` is the first character: + - `/me `: Send action text without `name:` + - `/up`: Show server uptime (self only) - `Shift+Z`: Admin menu (when role permissions allow) - `,` / `.`: Previous/next message - `<` / `>`: First/last message diff --git a/server/app/models.py b/server/app/models.py index b1fc89f..6085293 100644 --- a/server/app/models.py +++ b/server/app/models.py @@ -280,6 +280,7 @@ class BroadcastChatMessagePacket(BasePacket): senderId: str | None = None senderNickname: str | None = None system: bool = False + action: bool = False class PongPacket(BasePacket): diff --git a/server/app/server.py b/server/app/server.py index 529adc2..69cbb51 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -184,6 +184,7 @@ class SignalingServer: self._clock_announce_task: asyncio.Task[None] | None = None self._clock_top_of_hour_markers: dict[str, str] = {} self._clock_alarm_markers: dict[str, str] = {} + self._started_at_monotonic = time.monotonic() @staticmethod def _resolve_server_version() -> str: @@ -1572,6 +1573,81 @@ class SignalingServer: AdminActionResultPacket(type="admin_action_result", ok=ok, action=action, message=message), ) + @staticmethod + def _format_duration(total_seconds: int) -> str: + """Format a duration value as compact human-readable text.""" + + seconds = max(0, int(total_seconds)) + days, remainder = divmod(seconds, 24 * 60 * 60) + hours, remainder = divmod(remainder, 60 * 60) + minutes, secs = divmod(remainder, 60) + parts: list[str] = [] + if days: + parts.append(f"{days}d") + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if secs or not parts: + parts.append(f"{secs}s") + return " ".join(parts) + + def _format_uptime(self) -> str: + """Return current server uptime text.""" + + elapsed_seconds = int(max(0.0, time.monotonic() - self._started_at_monotonic)) + return self._format_duration(elapsed_seconds) + + async def _handle_chat_command(self, client: ClientConnection, message: str) -> bool: + """Handle slash commands in chat input; return True when handled.""" + + if not message.startswith("/"): + return False + command_line = message[1:] + command_token, separator, remainder = command_line.partition(" ") + command = command_token.casefold() + if command == "me": + if not separator or remainder == "": + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message="Usage: /me ", + system=True, + ), + ) + return True + await self._broadcast( + BroadcastChatMessagePacket( + type="chat_message", + message=f"{client.nickname} {remainder}", + senderId=client.id, + senderNickname=client.nickname, + system=False, + action=True, + ) + ) + return True + if command == "up": + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message=f"Server uptime: {self._format_uptime()}", + system=True, + ), + ) + return True + await self._send( + client.websocket, + BroadcastChatMessagePacket( + type="chat_message", + message=f"Unknown command: /{command_token}", + system=True, + ), + ) + return True + async def _handle_admin_packet(self, client: ClientConnection, packet: ClientPacket) -> bool: """Handle role/user administration packets with permission checks.""" @@ -2023,6 +2099,8 @@ class SignalingServer: ), ) return + if await self._handle_chat_command(client, packet.message): + return await self._broadcast( BroadcastChatMessagePacket( type="chat_message", diff --git a/server/tests/test_server_message_handling.py b/server/tests/test_server_message_handling.py index f69778d..5c37101 100644 --- a/server/tests/test_server_message_handling.py +++ b/server/tests/test_server_message_handling.py @@ -413,3 +413,87 @@ async def test_update_position_rate_reject_sends_self_correction(monkeypatch: py assert correction.id == "u1" assert correction.x == 5 assert correction.y == 5 + + +@pytest.mark.asyncio +async def test_chat_me_command_broadcasts_action(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="Tester") + server.clients[ws] = client + + broadcast_payloads: list[object] = [] + send_payloads: list[object] = [] + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_payloads.append(packet) + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server, "_send", fake_send) + + await server._handle_message(client, json.dumps({"type": "chat_message", "message": "/Me waves hello"})) + + assert send_payloads == [] + assert len(broadcast_payloads) == 1 + packet = broadcast_payloads[0] + assert getattr(packet, "type", "") == "chat_message" + assert packet.action is True + assert packet.system is False + assert packet.message == "Tester waves hello" + + +@pytest.mark.asyncio +async def test_chat_up_command_sends_sender_only(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="Tester") + server.clients[ws] = client + + broadcast_payloads: list[object] = [] + send_payloads: list[object] = [] + + async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None: + broadcast_payloads.append(packet) + + async def fake_send(websocket: ServerConnection, packet: object) -> None: + send_payloads.append(packet) + + monkeypatch.setattr(server, "_broadcast", fake_broadcast) + monkeypatch.setattr(server, "_send", fake_send) + monkeypatch.setattr(server, "_format_uptime", lambda: "1h 2m 3s") + + await server._handle_message(client, json.dumps({"type": "chat_message", "message": "/UP"})) + + assert broadcast_payloads == [] + assert len(send_payloads) == 1 + packet = send_payloads[0] + assert getattr(packet, "type", "") == "chat_message" + assert packet.system is True + assert packet.message == "Server uptime: 1h 2m 3s" + + +@pytest.mark.asyncio +async def test_chat_command_requires_leading_slash(monkeypatch: pytest.MonkeyPatch) -> None: + server = SignalingServer("127.0.0.1", 8765, None, None) + ws = _fake_ws() + client = ClientConnection(websocket=ws, id="u1", nickname="Tester") + 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": "chat_message", "message": " /up"})) + + assert len(broadcast_payloads) == 1 + packet = broadcast_payloads[0] + assert getattr(packet, "type", "") == "chat_message" + assert packet.system is False + assert packet.action is False + assert packet.message == " /up"