diff --git a/desktop-client/README.md b/desktop-client/README.md
index e69de29..afb74d9 100644
--- a/desktop-client/README.md
+++ b/desktop-client/README.md
@@ -0,0 +1,56 @@
+# VideoSync desktop client
+
+## Chat
+
+Chat follows new messages while you are near the bottom. Scroll up to read older
+messages without losing your place. Incoming messages appear beneath a **New
+messages** divider, and **Jump to latest** shows how many have arrived. Clicking
+it, or scrolling back to the bottom, resumes following messages.
+
+## Ready checks
+
+Send `/ready` in chat to start a check for everyone in the room. Playback pauses
+and a ready panel appears above chat. Each person clicks **I'm ready**; they can
+change their answer with **Not ready**. When everyone is connected and ready,
+the person who started the check can click **Start together**. They can also
+cancel the check. Repeating `/ready` leaves an existing check and its votes intact.
+
+New participants must confirm readiness. Disconnecting clears that person's
+vote; reconnecting restores the panel so they can confirm again. Ordinary play
+controls dismiss an active check and continue playback.
+
+Ready checks require the updated server and desktop clients. The web client
+does not yet have ready-check controls.
+
+## Fight-night commands
+
+- `/ko` — K.O.: That's a wrap.
+- `/bell` — DING!: Back to your corners.
+- `/tap` — TAP!: Someone's had enough.
+- `/robbery` — a reaction plus a 30-second room vote on the decision.
+
+Reactions slide into the bottom of the video for 2.2 seconds and keep playback
+running. A shared 3-second cooldown limits room-wide effects. Reaction events
+stay in chat history; joining or reconnecting does not replay animations.
+
+Quiet sound effects play automatically with visible reactions. Sounds are
+original synthesized effects cached locally, with no external downloads. Overlays and sound are
+suppressed while the player is hidden, minimized, or another application is active.
+Reactions also work while the detached chat window is focused.
+
+Robbery votes allow one answer per person, which you can change before the vote
+closes. The result remains visible after closing and can be dismissed. Joining
+or reconnecting restores the current vote. Fight commands require the updated
+server and desktop clients; web clients currently see the chat reaction only.
+
+## Tests
+
+With `pytest`, `pytest-qt`, and Bun installed, run from this directory:
+
+```powershell
+$env:QT_QPA_PLATFORM = 'offscreen'
+.\.venv\Scripts\python.exe -m pytest test_chat.py test_sync_client.py test_ready_server.py test_fight_reactions.py test_fight_server.py -q
+```
+
+The ready-check integration test starts an isolated local server; it does not
+contact the production service. VLC playback is mocked in the chat UI tests.
diff --git a/desktop-client/commands.py b/desktop-client/commands.py
index 78be26f..07ec763 100644
--- a/desktop-client/commands.py
+++ b/desktop-client/commands.py
@@ -6,6 +6,9 @@ HELP_TEXT = """
• /seek [time] - Seek (e.g., 1:23, +30s, -1m)
• /tag [now|time] [name] - Create a highlight
• /time - Show current timestamp
+• /ready - Ask everyone if they are ready to watch
+• /ko, /bell, /tap - Fight-night reactions
+• /robbery - React and open a room vote on the decision
• /help - Show this message
Keyboard Shortcuts:
• Space: Play/Pause
@@ -52,6 +55,14 @@ def parse_time_arg(arg: str, current_ms: int, length_ms: int):
def handle_chat_command(cmd: str, parts: list, widget):
"""Handle a slash command. Returns True if the command was handled."""
+ if cmd in ("/ko", "/bell", "/tap", "/robbery"):
+ widget.fight_action_requested.emit({"type": "reaction", "kind": cmd[1:]})
+ return True
+
+ if cmd == "/ready":
+ widget.ready_action_requested.emit({"type": "ready_check", "action": "begin"})
+ return True
+
if cmd == "/play":
if not widget.vlc_player.is_playing:
widget.toggle_playback()
diff --git a/desktop-client/fight_reactions.py b/desktop-client/fight_reactions.py
new file mode 100644
index 0000000..f7120ba
--- /dev/null
+++ b/desktop-client/fight_reactions.py
@@ -0,0 +1,146 @@
+"""Native ringside overlay and small, original synthesized reaction sounds."""
+import math
+from pathlib import Path
+import struct
+import wave
+
+from PyQt6.QtCore import Qt, QTimer, QPropertyAnimation, QPoint, QEasingCurve, QEvent
+from PyQt6.QtWidgets import QApplication, QWidget, QFrame, QLabel, QHBoxLayout, QVBoxLayout
+
+
+REACTIONS = {
+ "ko": ("K.O.", "That's a wrap.", "#ff5676"),
+ "bell": ("DING!", "Back to your corners.", "#f5c86c"),
+ "tap": ("TAP!", "Someone's had enough.", "#e1e1e1"),
+ "robbery": ("ROBBERY?", "Judges, explain yourselves.", "#f5c86c"),
+}
+
+
+def write_reaction_sound(directory, kind):
+ """Generate mono PCM once per cache directory, with no downloaded samples."""
+ if kind not in REACTIONS:
+ raise ValueError("Unknown reaction")
+ path = Path(directory) / f"{kind}.wav"
+ if path.exists():
+ return path
+ path.parent.mkdir(parents=True, exist_ok=True)
+ rate = 22050
+ duration = .85 if kind == "bell" else .4
+ samples = bytearray()
+ for i in range(int(rate * duration)):
+ t = i / rate
+ attack = min(1, t / .008)
+ if kind == "ko":
+ value = math.sin(2 * math.pi * (130 * t - 100 * t * t)) * math.exp(-13 * t)
+ elif kind == "bell":
+ value = sum(math.sin(2 * math.pi * f * t) * a for f, a in
+ ((880, .55), (1765, .25), (2410, .12))) * math.exp(-5 * t)
+ elif kind == "tap":
+ pulse = t if t < .16 else t - .16
+ value = math.sin(2 * math.pi * 420 * pulse) * math.exp(-45 * pulse)
+ else:
+ value = math.sin(2 * math.pi * 145 * t) * math.sin(2 * math.pi * 22 * t) * math.exp(-8 * t)
+ release = min(1, (duration - t) / .02)
+ samples.extend(struct.pack("{html.escape(state.get('requestedBy', 'Someone'))} started a ready check.")
+ if not self._chat_has_focus():
+ self.chat_toggle_btn.setText("💬•")
+ self.chat_toggle_btn.setToolTip("A ready check is waiting")
+ self.chat_toggle_btn.setStyleSheet("color: #66b3ff; border: 1px solid #66b3ff; border-radius: 6px;")
+ QApplication.alert(self.chat_container.window(), 3000)
+
+ def _send_ready_action(self, action, **extra):
+ if self._ready_check:
+ self.ready_action_requested.emit({"type": "ready_check", "action": action,
+ "id": self._ready_check['id'], **extra})
+
+ def _vote_ready(self):
+ mine = next((p for p in self._ready_check.get("participants", []) if p['username'] == self.username), {})
+ self._send_ready_action("vote", ready=not mine.get("ready", False))
+
+ def handle_fight_reaction(self, message):
+ kind = message.get("kind")
+ if kind not in REACTIONS:
+ return
+ position = format_time_short(int(message.get("position", 0) * 1000))
+ shown = self.ringside_strip.show_reaction(kind, message.get("username", "Someone"), position)
+ if shown:
+ self._play_reaction_sound(kind)
+
+ def _play_reaction_sound(self, kind):
+ try:
+ if kind not in self._reaction_sounds:
+ cache = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.GenericCacheLocation),
+ "VideoSync", "reaction-sounds-v1")
+ path = write_reaction_sound(cache, kind)
+ sound = QSoundEffect(self)
+ sound.setVolume(.18)
+ sound.setSource(QUrl.fromLocalFile(str(path)))
+ self._reaction_sounds[kind] = sound
+ self._reaction_sounds[kind].play()
+ except OSError:
+ # A read-only cache must not prevent the visual reaction.
+ pass
+
+ def update_fight_poll(self, state):
+ previous_id = self._fight_poll.get("id")
+ self._fight_poll = state if state.get("id") else {}
+ if not self._fight_poll:
+ self._fight_poll_timer.stop()
+ self.fight_poll_panel.hide()
+ return
+ if previous_id != state.get("id"):
+ self.fight_poll_panel.show()
+ self._refresh_fight_poll()
+ if state.get("active") and state.get("expiresAt", 0) > time.time() * 1000:
+ self._fight_poll_timer.start()
+
+ def _refresh_fight_poll(self):
+ if not self._fight_poll:
+ return
+ remaining = max(0, int((self._fight_poll.get("expiresAt", 0) - time.time() * 1000 + 999) / 1000))
+ active = self._fight_poll.get("active", False) and remaining > 0
+ if not active:
+ self._fight_poll_timer.stop()
+ votes = self._fight_poll.get("votes", {})
+ yes = sum(choice == "yes" for choice in votes.values())
+ no = sum(choice == "no" for choice in votes.values())
+ status = f"{remaining}s left" if active else "Vote closed"
+ mine = votes.get(self.username)
+ self.fight_poll_title.setText(f"Was that a robbery? · {status}")
+ self.fight_poll_result.setText(f"Absolutely {yes} · Fair decision {no}" +
+ (" · Your vote is in" if mine else ""))
+ for button, choice in ((self.fight_yes_btn, "yes"), (self.fight_no_btn, "no")):
+ button.setEnabled(bool(active and self._connected))
+ button.setStyleSheet("border: 1px solid #66b3ff;" if mine == choice else "")
+
+ def _vote_fight_poll(self, choice):
+ if self._fight_poll:
+ self.fight_action_requested.emit({"type": "fight_poll_vote", "id": self._fight_poll['id'], "choice": choice})
+
+ def reset_fight_reactions(self):
+ self.ringside_strip.dismiss()
+ self.update_fight_poll({})
+ for sound in self._reaction_sounds.values():
+ sound.stop()
def toPlainText(self):
# For compatibility with existing tests
@@ -812,10 +1061,16 @@ class RoomWidget(QWidget):
def clear_chat(self):
self._clear_chat_alert()
+ self._chat_scroll_timer.stop()
+ self._follow_chat = True
+ self._pending_chat_messages = 0
+ self._unread_divider = None
+ self.chat_jump_btn.hide()
for i in reversed(range(self.chat_content_layout.count())):
item = self.chat_content_layout.itemAt(i)
if item.widget():
- item.widget().setParent(None)
+ item.widget().deleteLater()
+ self.chat_content_layout.removeWidget(item.widget())
def send_chat(self):
text = self.chat_input.text().strip()
diff --git a/desktop-client/smoke_commands.py b/desktop-client/smoke_commands.py
new file mode 100644
index 0000000..4684c81
--- /dev/null
+++ b/desktop-client/smoke_commands.py
@@ -0,0 +1,68 @@
+"""Verify a deployed server with two isolated clients: python smoke_commands.py --url wss://host/ws."""
+import argparse
+import json
+import ssl
+import time
+
+import certifi
+from websockets.sync.client import connect
+
+
+def verify_commands(url):
+ options = {'ssl': ssl.create_default_context(cafile=certifi.where())} if url.startswith('wss:') else {}
+
+ def send(ws, **message):
+ ws.send(json.dumps(message))
+
+ def receive(ws, kind):
+ deadline = time.monotonic() + 8
+ while True:
+ message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
+ if message['type'] in ('error', 'reaction_error'):
+ raise RuntimeError(message.get('message'))
+ if message['type'] == kind:
+ return message
+
+ with connect(url, **options) as host, connect(url, **options) as guest:
+ file_info = {'name': 'command-smoke-test.mkv', 'size': 1234, 'duration': 60}
+ send(host, type='create_room', username='CommandSmokeHost', fileInfo=file_info)
+ code = receive(host, 'room_created')['code']
+ send(guest, type='join_room', username='CommandSmokeGuest', code=code)
+ receive(guest, 'room_file_check')
+ send(guest, type='confirm_join', fileInfo=file_info)
+ receive(guest, 'room_joined')
+ receive(host, 'user_joined')
+ send(host, type='ready_check', action='begin')
+ check = receive(host, 'ready_check')
+ assert check['active'] and receive(guest, 'ready_check')['id'] == check['id']
+ for ws in (host, guest):
+ send(ws, type='ready_check', action='vote', id=check['id'], ready=True)
+ for peer in (host, guest):
+ receive(peer, 'ready_check')
+ send(host, type='ready_check', action='start', id=check['id'])
+ for peer in (host, guest):
+ assert receive(peer, 'sync')['action'] == 'play'
+ assert not receive(peer, 'ready_check')['active']
+ print('PASS /ready: shared panel state, votes, synchronized start', flush=True)
+ for i, kind in enumerate(('ko', 'bell', 'tap', 'robbery')):
+ if i:
+ time.sleep(3.2)
+ send(host, type='reaction', kind=kind)
+ for peer in (host, guest):
+ assert receive(peer, 'reaction')['kind'] == kind
+ assert receive(peer, 'chat')['message'] == '🥊 /' + kind
+ print(f'PASS /{kind}: delivered to both clients', flush=True)
+ poll = receive(host, 'fight_poll')
+ assert receive(guest, 'fight_poll')['id'] == poll['id']
+ send(guest, type='fight_poll_vote', id=poll['id'], choice='yes')
+ for peer in (host, guest):
+ assert receive(peer, 'fight_poll')['votes']['CommandSmokeGuest'] == 'yes'
+ send(host, type='request_state')
+ assert receive(host, 'state')['state']['playing']
+ print('PASS /robbery vote and uninterrupted playback', flush=True)
+
+
+if __name__ == '__main__':
+ parser = argparse.ArgumentParser()
+ parser.add_argument('--url', required=True)
+ verify_commands(parser.parse_args().url)
diff --git a/desktop-client/sync_client.py b/desktop-client/sync_client.py
index f1b4299..502c831 100644
--- a/desktop-client/sync_client.py
+++ b/desktop-client/sync_client.py
@@ -21,6 +21,9 @@ class SyncClientThread(QThread):
chat_message = pyqtSignal(str, str, int) # author, text, timestamp
system_message = pyqtSignal(str)
sync_event = pyqtSignal(dict)
+ ready_check_updated = pyqtSignal(dict)
+ fight_reaction = pyqtSignal(dict)
+ fight_poll_updated = pyqtSignal(dict)
latency_updated = pyqtSignal(int) # latency in ms
def __init__(self, url="ws://localhost:3000/ws"):
@@ -150,7 +153,7 @@ class SyncClientThread(QThread):
elif t == "room_file_check":
self.file_check_needed.emit(msg)
- elif t in ["user_joined", "user_left"]:
+ elif t in ["user_joined", "user_left", "user_rejoined"]:
self.users_updated.emit(msg.get("users", []))
elif t == "chat":
@@ -162,3 +165,15 @@ class SyncClientThread(QThread):
elif t == "sync":
self.sync_event.emit(msg)
+
+ elif t == "ready_check":
+ self.ready_check_updated.emit(msg)
+
+ elif t == "reaction":
+ self.fight_reaction.emit(msg)
+
+ elif t == "fight_poll":
+ self.fight_poll_updated.emit(msg)
+
+ elif t == "reaction_error":
+ self.system_message.emit(msg.get("message", "Reaction unavailable"))
diff --git a/desktop-client/test_chat.py b/desktop-client/test_chat.py
index 324e589..4a01153 100644
--- a/desktop-client/test_chat.py
+++ b/desktop-client/test_chat.py
@@ -17,6 +17,7 @@ class PlayerSignals(QObject):
def room(qtbot, monkeypatch):
player = Mock(signals=PlayerSignals())
monkeypatch.setattr(room_widget, 'VLCSyncPlayer', lambda frame: player)
+ monkeypatch.setattr(room_widget.RoomWidget, '_play_reaction_sound', Mock())
widget = room_widget.RoomWidget()
widget.username = 'Me'
qtbot.addWidget(widget)
@@ -101,3 +102,118 @@ def test_popout_focus_and_wrapping(room, qtbot, monkeypatch):
room.add_chat_message('Other', 'Background popout', 0)
assert room._unread_messages == 1
popout.close()
+
+
+def test_scrolling_preserves_position_and_counts_unread(room, qtbot):
+ for i in range(30):
+ room.add_chat_message('Other', f'Message {i}: ' + 'wrapped words ' * 10, 0)
+ qtbot.wait(150)
+ bar = room.chat_messages.verticalScrollBar()
+ assert bar.value() == bar.maximum()
+ bar.setValue(100)
+ qtbot.wait(30)
+ position = bar.value()
+ room.add_chat_message('Other', 'First unread message', 0)
+ room.add_chat_message('Other', 'Second unread message', 0)
+ room.add_system_message('Someone paused')
+ qtbot.wait(150)
+ assert bar.value() == position
+ assert room._pending_chat_messages == 2
+ assert not room.chat_jump_btn.isHidden()
+ assert room._unread_divider is not None
+ room.chat_input.setFocus()
+ assert room._pending_chat_messages == 2
+ room.jump_to_latest()
+ qtbot.wait(150)
+ assert bar.value() == bar.maximum()
+ assert room.chat_jump_btn.isHidden()
+ room.add_chat_message('Other', 'Follow latest', 0)
+ qtbot.wait(150)
+ assert bar.value() == bar.maximum()
+ assert room._pending_chat_messages == 0
+
+
+def test_scroll_back_to_bottom_clears_pending(room, qtbot):
+ for i in range(20):
+ room.add_chat_message('Other', 'Words ' * 30, 0)
+ qtbot.wait(150)
+ bar = room.chat_messages.verticalScrollBar()
+ bar.setValue(0)
+ room.add_chat_message('Other', 'Unread', 0)
+ qtbot.wait(100)
+ bar.setValue(bar.maximum())
+ assert room._pending_chat_messages == 0
+ assert room._follow_chat
+ assert room.chat_jump_btn.isHidden()
+ room.clear_chat()
+ assert room._unread_divider is None
+ assert room.chat_content_layout.count() == 0
+
+
+def test_ready_command_and_controls(room, qtbot):
+ with qtbot.waitSignal(room.ready_action_requested) as signal:
+ room.chat_input.setText('/ready')
+ room.send_chat()
+ assert signal.args == [{'type': 'ready_check', 'action': 'begin'}]
+ assert room.chat_input.text() == ''
+ state = {'active': True, 'id': 'check-1', 'requestedBy': 'Me', 'participants': [
+ {'username': 'Me', 'ready': False, 'connected': True},
+ {'username': "O'Brien", 'ready': True, 'connected': True}]}
+ room.update_ready_check(state)
+ assert not room.ready_panel.isHidden()
+ assert not room.ready_start_btn.isEnabled()
+ assert "O'Brien" in room.ready_users.text()
+ with qtbot.waitSignal(room.ready_action_requested) as signal:
+ room.ready_btn.click()
+ assert signal.args[0] == {'type': 'ready_check', 'action': 'vote', 'id': 'check-1', 'ready': True}
+ state['participants'][0]['ready'] = True
+ room.update_ready_check(state)
+ assert room.ready_start_btn.isEnabled()
+ with qtbot.waitSignal(room.ready_action_requested) as signal:
+ room.ready_start_btn.click()
+ assert signal.args[0]['action'] == 'start'
+ state['participants'][1]['connected'] = False
+ room.update_ready_check(state)
+ assert not room.ready_start_btn.isEnabled()
+ assert 'Reconnecting' in room.ready_users.text()
+ state['requestedBy'] = 'Other'
+ room.update_ready_check(state)
+ assert room.ready_start_btn.isHidden()
+ room.update_ready_check({'active': False})
+ assert room.ready_panel.isHidden()
+
+
+def test_ready_panel_buttons_fit_styled_chat(room, qtbot):
+ from main import VlcSyncApp
+ VlcSyncApp.apply_stylesheet(room)
+ room.splitter.setSizes([750, 300])
+ room.update_ready_check({'active': True, 'id': 'layout', 'requestedBy': 'Me', 'participants': [
+ {'username': 'Me', 'ready': True, 'connected': True}]})
+ qtbot.wait(100)
+ for button in (room.ready_btn, room.ready_start_btn, room.ready_cancel_btn):
+ assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 20
+ room.update_connection_status(False)
+ room.update_ready_check(room._ready_check)
+ assert not room.ready_start_btn.isEnabled()
+ assert not room.ready_btn.isEnabled()
+
+
+def test_app_wires_commands_to_websocket_and_incoming_panels(room, qtbot, monkeypatch):
+ import main
+ from sync_client import SyncClientThread
+ client = SyncClientThread()
+ client.send_message = Mock()
+ monkeypatch.setattr(main, 'SyncClientThread', lambda url: client)
+ app = main.VlcSyncApp()
+ qtbot.addWidget(app)
+ for command, expected in [('/ready', {'type': 'ready_check', 'action': 'begin'}),
+ ('/ko', {'type': 'reaction', 'kind': 'ko'}),
+ ('/bell', {'type': 'reaction', 'kind': 'bell'})]:
+ app.room_widget.chat_input.setText(command)
+ app.room_widget.send_chat()
+ client.send_message.assert_called_with(expected)
+ state = {'type': 'ready_check', 'active': True, 'id': 'wired', 'requestedBy': 'Me',
+ 'participants': [{'username': 'Me', 'ready': False, 'connected': True}]}
+ client._handle_message(state)
+ assert app.room_widget._ready_check['id'] == 'wired'
+ assert not app.room_widget.ready_panel.isHidden()
diff --git a/desktop-client/test_fight_reactions.py b/desktop-client/test_fight_reactions.py
new file mode 100644
index 0000000..87ad53d
--- /dev/null
+++ b/desktop-client/test_fight_reactions.py
@@ -0,0 +1,149 @@
+import time
+import wave
+from unittest.mock import Mock
+
+import pytest
+from PyQt6.QtCore import QEvent, QUrl
+from PyQt6.QtMultimedia import QSoundEffect
+from PyQt6.QtWidgets import QApplication
+
+from fight_reactions import REACTIONS, write_reaction_sound
+from test_chat import room
+
+
+@pytest.mark.parametrize('kind', REACTIONS)
+def test_command_dispatch(room, qtbot, kind):
+ with qtbot.waitSignal(room.fight_action_requested) as event:
+ room.chat_input.setText('/' + kind)
+ room.send_chat()
+ assert event.args == [{'type': 'reaction', 'kind': kind}]
+ assert room.chat_input.text() == ''
+ assert room.chat_content_layout.count() == 0 # Wait for the server echo.
+
+
+@pytest.mark.parametrize('kind', REACTIONS)
+def test_strip_is_native_positioned_and_does_not_steal_focus(room, qtbot, kind):
+ room.activateWindow()
+ room.chat_input.setFocus()
+ qtbot.wait(30)
+ room.handle_fight_reaction({'kind': kind, 'username': "O'Brien", 'position': 83})
+ strip = room.ringside_strip
+ assert strip.isWindow()
+ assert strip.isVisible()
+ assert strip.headline.text() == REACTIONS[kind][0]
+ assert "O'Brien" in strip.caption.text()
+ assert '01:23' in strip.metadata.text()
+ qtbot.wait(210)
+ assert QApplication.focusWidget() is room.chat_input
+ video = room.video_frame
+ rect = strip.geometry()
+ from PyQt6.QtCore import QRect, QPoint
+ bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
+ assert bounds.contains(rect)
+ room.resize(1150, 650)
+ qtbot.wait(50)
+ bounds = QRect(video.mapToGlobal(QPoint(0, 0)), video.size())
+ assert bounds.contains(strip.geometry())
+ QApplication.sendEvent(room, QEvent(QEvent.Type.WindowDeactivate))
+ assert not strip.isVisible()
+
+
+def test_strip_auto_dismiss_and_cleanup(room, qtbot):
+ room.activateWindow()
+ qtbot.wait(30)
+ room.handle_fight_reaction({'kind': 'ko', 'username': 'Alex', 'position': 0})
+ assert room.ringside_strip.isVisible()
+ room.ringside_strip.dismiss_timer.start(30)
+ qtbot.waitUntil(lambda: not room.ringside_strip.isVisible())
+ room.handle_fight_reaction({'kind': 'bell', 'username': 'Alex', 'position': 0})
+ room.cleanup()
+ assert not room.ringside_strip.isVisible()
+ assert not room.ringside_strip.dismiss_timer.isActive()
+ assert not room._fight_poll_timer.isActive()
+
+
+def test_reactions_work_with_chat_popout_focused(room, qtbot):
+ room.popout_chat()
+ popout = room.popout_window
+ qtbot.addWidget(popout)
+ popout.activateWindow()
+ room.chat_input.setFocus()
+ qtbot.wait(50)
+ room.handle_fight_reaction({'kind': 'ko', 'username': 'Other', 'position': 0})
+ assert room.ringside_strip.isVisible()
+ assert QApplication.focusWidget() is room.chat_input
+ popout.close()
+
+
+def test_sound_is_enabled_by_default_and_background_effects_are_suppressed(room, qtbot, monkeypatch):
+ play = Mock()
+ monkeypatch.setattr(room, '_play_reaction_sound', play)
+ room.activateWindow()
+ qtbot.wait(30)
+ room.handle_fight_reaction({'kind': 'ko', 'position': 0})
+ play.assert_called_once_with('ko')
+ play.reset_mock()
+ room.handle_fight_reaction({'kind': 'bell', 'position': 0})
+ play.assert_called_once_with('bell')
+ room.hide()
+ room.handle_fight_reaction({'kind': 'tap', 'position': 0})
+ play.assert_called_once_with('bell')
+ assert not room.ringside_strip.isVisible()
+
+
+@pytest.mark.parametrize('kind', REACTIONS)
+def test_original_sound_files_are_valid_and_loadable(qtbot, tmp_path, kind):
+ path = write_reaction_sound(tmp_path, kind)
+ assert write_reaction_sound(tmp_path, kind) == path
+ with wave.open(str(path)) as audio:
+ assert audio.getnchannels() == 1
+ assert audio.getsampwidth() == 2
+ assert audio.getframerate() == 22050
+ assert .35 <= audio.getnframes() / 22050 <= .9
+ assert any(audio.readframes(audio.getnframes()))
+ sound = QSoundEffect()
+ sound.setSource(QUrl.fromLocalFile(str(path)))
+ qtbot.waitUntil(lambda: sound.status() in (QSoundEffect.Status.Ready, QSoundEffect.Status.Error))
+ assert sound.status() == QSoundEffect.Status.Ready
+
+
+def test_robbery_vote_controls_expiry_and_reconnect(room, qtbot):
+ poll = {'id': 'vote-1', 'active': True, 'expiresAt': time.time() * 1000 + 30000,
+ 'votes': {'Other': 'yes'}}
+ room.update_fight_poll(poll)
+ assert not room.fight_poll_panel.isHidden()
+ assert 'Absolutely 1' in room.fight_poll_result.text()
+ with qtbot.waitSignal(room.fight_action_requested) as event:
+ room.fight_no_btn.click()
+ assert event.args == [{'type': 'fight_poll_vote', 'id': 'vote-1', 'choice': 'no'}]
+ poll['votes']['Me'] = 'no'
+ room.update_fight_poll(poll)
+ assert 'Fair decision 1' in room.fight_poll_result.text()
+ assert 'Your vote is in' in room.fight_poll_result.text()
+ room.update_connection_status(False)
+ assert not room.fight_yes_btn.isEnabled()
+ room.update_connection_status(True)
+ assert room.fight_yes_btn.isEnabled()
+ poll['expiresAt'] = time.time() * 1000 - 1000
+ room.update_fight_poll(poll)
+ assert 'Vote closed' in room.fight_poll_title.text()
+ assert not room.fight_yes_btn.isEnabled()
+ assert not room._fight_poll_timer.isActive()
+ room.reset_fight_reactions()
+ assert room.fight_poll_panel.isHidden()
+
+
+def test_styled_vote_controls_fit_and_dismiss_stays_dismissed(room, qtbot):
+ from main import VlcSyncApp
+ VlcSyncApp.apply_stylesheet(room)
+ room.splitter.setSizes([750, 350])
+ poll = {'id': 'styled', 'active': True, 'expiresAt': time.time() * 1000 + 30000, 'votes': {}}
+ room.update_fight_poll(poll)
+ qtbot.wait(50)
+ for button in (room.fight_yes_btn, room.fight_no_btn, room.fight_poll_dismiss_btn):
+ assert button.width() >= button.fontMetrics().horizontalAdvance(button.text()) + 10
+ room.fight_poll_dismiss_btn.click()
+ room.update_fight_poll(poll)
+ assert room.fight_poll_panel.isHidden()
+ room.update_fight_poll({**poll, 'id': 'new-poll'})
+ assert not room.fight_poll_panel.isHidden()
diff --git a/desktop-client/test_fight_server.py b/desktop-client/test_fight_server.py
new file mode 100644
index 0000000..f038681
--- /dev/null
+++ b/desktop-client/test_fight_server.py
@@ -0,0 +1,104 @@
+import json
+import time
+
+import pytest
+from websockets.sync.client import connect
+
+from test_ready_server import server_url, send, receive
+
+
+def join_pair(host, guest):
+ file_info = {'name': 'fight.mkv', 'size': 1234, 'duration': 60}
+ send(host, type='create_room', username='Host', fileInfo=file_info)
+ code = receive(host, 'room_created')['code']
+ send(guest, type='join_room', username='Guest', code=code)
+ receive(guest, 'room_file_check')
+ send(guest, type='confirm_join', fileInfo=file_info)
+ receive(guest, 'room_joined')
+ receive(host, 'user_joined')
+ return code, file_info
+
+
+@pytest.mark.parametrize('kind', ['ko', 'bell', 'tap', 'robbery'])
+def test_reactions_are_room_wide_and_preserve_playback(server_url, kind):
+ with connect(server_url) as host, connect(server_url) as guest:
+ code, file_info = join_pair(host, guest)
+ # An unconfirmed client cannot broadcast or consume the cooldown.
+ with connect(server_url) as pending:
+ send(pending, type='join_room', username='Pending', code=code)
+ receive(pending, 'room_file_check')
+ send(pending, type='reaction', kind=kind)
+ send(pending, type='ping', timestamp=1)
+ assert receive(pending, 'pong')['timestamp'] == 1
+ send(host, type='reaction', kind='invalid')
+ assert 'Unknown' in receive(host, 'reaction_error')['message']
+ send(host, type='sync', action='play', position=18)
+ receive(guest, 'sync')
+ send(host, type='reaction', kind=kind, username='Spoofed')
+ reaction = receive(host, 'reaction')
+ assert reaction['kind'] == kind and reaction['username'] == 'Host'
+ assert reaction['position'] >= 18
+ assert receive(guest, 'reaction') == reaction
+ assert receive(host, 'chat')['message'] == '🥊 /' + kind
+ receive(guest, 'chat')
+ if kind == 'robbery':
+ assert receive(host, 'fight_poll')['active']
+ receive(guest, 'fight_poll')
+ send(guest, type='reaction', kind='bell')
+ assert 'cooldown' in receive(guest, 'reaction_error')['message']
+ send(host, type='request_state')
+ state = receive(host, 'state')['state']
+ assert state['playing'] and state['position'] >= 18
+ with pytest.raises(TimeoutError):
+ host.recv(timeout=.1)
+ # Reconnecting restores chat history without replaying transient effects.
+ guest.close()
+ with connect(server_url) as restored:
+ send(restored, type='rejoin_room', username='Guest', code=code)
+ snapshot = receive(restored, 'room_rejoined')
+ assert snapshot['chatHistory'][-1]['message'] == '🥊 /' + kind
+ with pytest.raises(TimeoutError):
+ restored.recv(timeout=.1)
+
+
+def test_robbery_votes_are_unique_restored_and_expire(server_url):
+ with connect(server_url) as host, connect(server_url) as guest:
+ code, file_info = join_pair(host, guest)
+ send(host, type='reaction', kind='robbery')
+ poll = receive(host, 'fight_poll')
+ poll_id = poll['id']
+ assert poll['votes'] == {} and poll['active']
+ receive(guest, 'fight_poll')
+ send(host, type='fight_poll_vote', id=poll_id, choice='yes')
+ assert receive(host, 'fight_poll')['votes'] == {'Host': 'yes'}
+ receive(guest, 'fight_poll')
+ send(host, type='fight_poll_vote', id=poll_id, choice='no')
+ assert receive(host, 'fight_poll')['votes'] == {'Host': 'no'}
+ receive(guest, 'fight_poll')
+ send(guest, type='fight_poll_vote', id=poll_id, choice='yes', username='Host')
+ assert receive(host, 'fight_poll')['votes'] == {'Host': 'no', 'Guest': 'yes'}
+ receive(guest, 'fight_poll')
+ for vote_id, choice in [('stale', 'yes'), (poll_id, 'invalid')]:
+ send(guest, type='fight_poll_vote', id=vote_id, choice=choice)
+ send(guest, type='ping', timestamp=2)
+ assert json.loads(guest.recv(timeout=3))['type'] == 'pong'
+ guest.close()
+ with connect(server_url) as restored:
+ send(restored, type='rejoin_room', username='Guest', code=code)
+ snapshot = receive(restored, 'room_rejoined')['fightPoll']
+ assert snapshot['id'] == poll_id and snapshot['votes']['Guest'] == 'yes'
+ receive(host, 'user_rejoined')
+ time.sleep(3.1)
+ send(restored, type='reaction', kind='robbery')
+ assert 'already open' in receive(restored, 'reaction_error')['message']
+ assert receive(restored, 'fight_poll')['id'] == poll_id
+ closed = json.loads(host.recv(timeout=35))
+ assert closed['type'] == 'fight_poll' and not closed['active']
+ assert closed['votes'] == {'Host': 'no', 'Guest': 'yes'}
+ assert not receive(restored, 'fight_poll')['active']
+ send(restored, type='fight_poll_vote', id=poll_id, choice='no')
+ send(restored, type='ping', timestamp=3)
+ assert json.loads(restored.recv(timeout=3))['type'] == 'pong'
+ send(host, type='reaction', kind='robbery')
+ replacement = receive(host, 'fight_poll')
+ assert replacement['id'] != poll_id and replacement['votes'] == {}
diff --git a/desktop-client/test_ready_server.py b/desktop-client/test_ready_server.py
new file mode 100644
index 0000000..9975889
--- /dev/null
+++ b/desktop-client/test_ready_server.py
@@ -0,0 +1,133 @@
+"""Exercise ready checks through real WebSockets against an isolated local server."""
+import json
+import os
+from pathlib import Path
+import shutil
+import socket
+import subprocess
+import time
+
+import pytest
+from websockets.sync.client import connect
+
+
+@pytest.fixture
+def server_url():
+ with socket.socket() as reservation:
+ reservation.bind(('127.0.0.1', 0))
+ port = reservation.getsockname()[1]
+ bun = shutil.which('bun')
+ assert bun, 'Bun is required to test the local server'
+ process = subprocess.Popen(
+ [bun, 'run', 'server.ts'], cwd=Path(__file__).resolve().parent.parent,
+ env={**os.environ, 'PORT': str(port)}, stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))
+ try:
+ deadline = time.monotonic() + 5
+ while True:
+ if process.poll() is not None:
+ pytest.fail(process.stderr.read().decode())
+ try:
+ with socket.create_connection(('127.0.0.1', port), timeout=.1):
+ break
+ except OSError:
+ if time.monotonic() >= deadline:
+ pytest.fail('Local server did not start')
+ time.sleep(.05)
+ yield f'ws://127.0.0.1:{port}/ws'
+ finally:
+ process.terminate()
+ process.wait(timeout=5)
+ process.stderr.close()
+
+
+def send(ws, **message):
+ ws.send(json.dumps(message))
+
+
+def receive(ws, kind, predicate=lambda message: True):
+ deadline = time.monotonic() + 3
+ while True:
+ message = json.loads(ws.recv(timeout=max(.01, deadline - time.monotonic())))
+ if message['type'] == kind and predicate(message):
+ return message
+
+
+def test_room_ready_check_lifecycle(server_url):
+ file_info = {'name': 'test.mkv', 'size': 1234, 'duration': 60}
+ with connect(server_url) as host, connect(server_url) as guest:
+ send(host, type='create_room', username='Host', fileInfo=file_info)
+ code = receive(host, 'room_created')['code']
+ send(guest, type='join_room', username='Guest', code=code)
+ receive(guest, 'room_file_check')
+ send(guest, type='confirm_join', fileInfo=file_info)
+ receive(guest, 'room_joined')
+ receive(host, 'user_joined')
+ send(host, type='sync', action='play', position=12)
+ receive(guest, 'sync')
+ send(host, type='ready_check', action='begin')
+ pause = receive(host, 'sync')
+ assert pause['action'] == 'pause' and pause['position'] >= 12
+ check = receive(host, 'ready_check')
+ check_id = check['id']
+ assert check['requestedBy'] == 'Host'
+ assert not any(p['ready'] for p in check['participants'])
+ assert receive(guest, 'ready_check')['id'] == check_id
+
+ # Unconfirmed peers cannot create or vote in a check.
+ with connect(server_url) as pending:
+ send(pending, type='join_room', username='Pending', code=code)
+ receive(pending, 'room_file_check')
+ send(pending, type='ready_check', action='begin')
+ send(pending, type='ready_check', action='vote', id=check_id, ready=True)
+ send(host, type='ready_check', action='start', id=check_id)
+ send(host, type='ready_check', action='vote', id=check_id, ready=True)
+ check = receive(host, 'ready_check')
+ assert check['active'] and sum(p['ready'] for p in check['participants']) == 1
+ receive(guest, 'ready_check')
+ send(host, type='ready_check', action='begin')
+ repeated = receive(host, 'ready_check')
+ assert repeated['id'] == check_id
+ assert sum(p['ready'] for p in repeated['participants']) == 1
+ receive(guest, 'ready_check')
+ send(guest, type='ready_check', action='vote', id=check_id, ready=True)
+ assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
+ receive(guest, 'ready_check')
+
+ # Non-initiators cannot start. Stale IDs cannot change votes.
+ send(guest, type='ready_check', action='start', id=check_id)
+ send(guest, type='ready_check', action='vote', id='stale', ready=False)
+ send(guest, type='ready_check', action='begin')
+ assert all(p['ready'] for p in receive(host, 'ready_check')['participants'])
+ receive(guest, 'ready_check')
+
+ # Dropping a connection revokes readiness and blocks starting.
+ guest.close()
+ disconnected = receive(host, 'ready_check')
+ guest_state = next(p for p in disconnected['participants'] if p['username'] == 'Guest')
+ assert not guest_state['connected'] and not guest_state['ready']
+ send(host, type='ready_check', action='start', id=check_id)
+ with connect(server_url) as rejoined:
+ send(rejoined, type='rejoin_room', username='Guest', code=code)
+ restored = receive(rejoined, 'room_rejoined')['readyCheck']
+ assert restored['id'] == check_id
+ assert not next(p for p in restored['participants'] if p['username'] == 'Guest')['ready']
+ receive(host, 'ready_check')
+ receive(rejoined, 'ready_check')
+ send(rejoined, type='ready_check', action='vote', id=check_id, ready=True)
+ receive(host, 'ready_check')
+ receive(rejoined, 'ready_check')
+ send(host, type='ready_check', action='start', id=check_id)
+ assert receive(host, 'sync')['action'] == 'play'
+ assert receive(rejoined, 'sync')['action'] == 'play'
+ assert not receive(host, 'ready_check')['active']
+ assert not receive(rejoined, 'ready_check')['active']
+
+ send(host, type='ready_check', action='begin')
+ new_check = receive(host, 'ready_check')
+ assert new_check['id'] != check_id
+ assert not any(p['ready'] for p in new_check['participants'])
+ receive(rejoined, 'ready_check')
+ send(host, type='ready_check', action='cancel', id=new_check['id'])
+ assert not receive(host, 'ready_check')['active']
+ assert not receive(rejoined, 'ready_check')['active']
diff --git a/desktop-client/test_sync_client.py b/desktop-client/test_sync_client.py
index c76d47e..0eb1b5a 100644
--- a/desktop-client/test_sync_client.py
+++ b/desktop-client/test_sync_client.py
@@ -22,3 +22,23 @@ def test_certificate_error_finds_nested_verification_error():
def test_non_certificate_error_is_ignored():
assert SyncClientThread._certificate_error(ConnectionError("offline")) is None
+
+
+def test_ready_check_event_dispatch(qtbot):
+ client = SyncClientThread()
+ message = {'type': 'ready_check', 'active': True, 'id': 'check'}
+ with qtbot.waitSignal(client.ready_check_updated) as signal:
+ client._handle_message(message)
+ assert signal.args == [message]
+
+
+def test_fight_event_dispatch_and_nonfatal_feedback(qtbot):
+ client = SyncClientThread()
+ for event_type, signal in (('reaction', client.fight_reaction), ('fight_poll', client.fight_poll_updated)):
+ message = {'type': event_type, 'kind': 'ko'}
+ with qtbot.waitSignal(signal) as event:
+ client._handle_message(message)
+ assert event.args == [message]
+ with qtbot.waitSignal(client.system_message) as event:
+ client._handle_message({'type': 'reaction_error', 'message': 'Cooldown'})
+ assert event.args == ['Cooldown']
diff --git a/server.ts b/server.ts
index 09f47f0..762efdc 100644
--- a/server.ts
+++ b/server.ts
@@ -16,6 +16,9 @@ interface RoomState {
lastUpdate: number; // timestamp when position was last set
users: Map;
chatHistory: ChatMessage[];
+ readyCheck?: { id: string; requestedBy: string; ready: Set };
+ lastReactionAt?: number;
+ fightPoll?: { id: string; expiresAt: number; votes: Map };
}
interface ChatMessage {
@@ -56,6 +59,29 @@ function getUserList(room: RoomState): string[] {
return Array.from(room.users.keys());
}
+function readyCheckState(room: RoomState) {
+ if (!room.readyCheck) return { type: "ready_check", active: false };
+ return {
+ type: "ready_check", active: true,
+ id: room.readyCheck.id, requestedBy: room.readyCheck.requestedBy,
+ participants: Array.from(room.users, ([username, ws]) => ({
+ username, ready: room.readyCheck!.ready.has(username), connected: ws.readyState === 1,
+ })),
+ };
+}
+
+function broadcastReadyCheck(room: RoomState) {
+ broadcastToRoom(room, readyCheckState(room));
+}
+
+function fightPollState(room: RoomState) {
+ const poll = room.fightPoll;
+ if (!poll) return { type: "fight_poll", active: false };
+ return { type: "fight_poll", id: poll.id, active: Date.now() < poll.expiresAt,
+ expiresAt: poll.expiresAt,
+ votes: Object.fromEntries(Array.from(poll.votes).filter(([username]) => room.users.has(username))) };
+}
+
// --- MIME Types ---
const mimeTypes: Record = {
".html": "text/html",
@@ -253,6 +279,8 @@ const server = Bun.serve({
position: getCurrentPosition(room),
},
chatHistory: room.chatHistory.slice(-50),
+ readyCheck: readyCheckState(room),
+ fightPoll: fightPollState(room),
})
);
@@ -267,12 +295,17 @@ const server = Bun.serve({
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} joined`);
+ if (room.readyCheck) broadcastReadyCheck(room);
break;
}
case "sync": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
+ if (msg.action === "play" && room.readyCheck) {
+ room.readyCheck = undefined;
+ broadcastReadyCheck(room);
+ }
// Update authoritative state
if (msg.action === "play") {
@@ -305,6 +338,89 @@ const server = Bun.serve({
break;
}
+ case "ready_check": {
+ const room = rooms.get(ws.data.roomCode);
+ const username = ws.data.username;
+ if (!room || room.users.get(username) !== (ws as unknown as WebSocket)) return;
+ if (msg.action === "begin") {
+ // Repeating /ready shows the existing check without resetting votes.
+ if (!room.readyCheck) {
+ room.readyCheck = { id: crypto.randomUUID(), requestedBy: username, ready: new Set() };
+ room.position = getCurrentPosition(room);
+ room.playing = false;
+ room.lastUpdate = Date.now();
+ broadcastToRoom(room, { type: "sync", action: "pause", position: room.position, playing: false });
+ }
+ } else {
+ const check = room.readyCheck;
+ if (!check || msg.id !== check.id) return;
+ if (msg.action === "vote" && typeof msg.ready === "boolean") {
+ if (msg.ready) check.ready.add(username);
+ else check.ready.delete(username);
+ } else if (msg.action === "cancel" && check.requestedBy === username) {
+ room.readyCheck = undefined;
+ } else if (msg.action === "start" && check.requestedBy === username) {
+ const everyoneReady = Array.from(room.users).every(([name, peer]) =>
+ peer.readyState === 1 && check.ready.has(name));
+ if (!everyoneReady) return;
+ room.readyCheck = undefined;
+ room.playing = true;
+ room.lastUpdate = Date.now();
+ broadcastToRoom(room, { type: "sync", action: "play", position: room.position, playing: true });
+ } else return;
+ }
+ broadcastReadyCheck(room);
+ break;
+ }
+
+ case "reaction": {
+ const room = rooms.get(ws.data.roomCode);
+ if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
+ if (!["ko", "bell", "tap", "robbery"].includes(msg.kind)) {
+ ws.send(JSON.stringify({ type: "reaction_error", message: "Unknown fight reaction." }));
+ return;
+ }
+ const now = Date.now();
+ if (room.lastReactionAt !== undefined && now - room.lastReactionAt < 3000) {
+ ws.send(JSON.stringify({ type: "reaction_error", message: "Fight reactions have a 3-second room cooldown." }));
+ return;
+ }
+ if (msg.kind === "robbery" && room.fightPoll && now < room.fightPoll.expiresAt) {
+ ws.send(JSON.stringify({ type: "reaction_error", message: "A robbery vote is already open." }));
+ ws.send(JSON.stringify(fightPollState(room)));
+ return;
+ }
+ room.lastReactionAt = now;
+ broadcastToRoom(room, { type: "reaction", kind: msg.kind,
+ username: ws.data.username, position: getCurrentPosition(room), timestamp: now });
+ const chatMsg = { username: ws.data.username, message: `🥊 /${msg.kind}`, timestamp: now };
+ room.chatHistory.push(chatMsg);
+ if (room.chatHistory.length > 200) room.chatHistory = room.chatHistory.slice(-100);
+ broadcastToRoom(room, { type: "chat", ...chatMsg });
+ if (msg.kind === "robbery") {
+ const poll = { id: crypto.randomUUID(), expiresAt: now + 30_000, votes: new Map() };
+ room.fightPoll = poll;
+ broadcastToRoom(room, fightPollState(room));
+ setTimeout(() => {
+ if (rooms.get(room.code) === room && room.fightPoll?.id === poll.id) {
+ broadcastToRoom(room, fightPollState(room));
+ }
+ }, 30_000);
+ }
+ break;
+ }
+
+ case "fight_poll_vote": {
+ const room = rooms.get(ws.data.roomCode);
+ if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
+ const poll = room.fightPoll;
+ if (!poll || poll.id !== msg.id || Date.now() >= poll.expiresAt) return;
+ if (msg.choice !== "yes" && msg.choice !== "no") return;
+ poll.votes.set(ws.data.username, msg.choice);
+ broadcastToRoom(room, fightPollState(room));
+ break;
+ }
+
case "chat": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
@@ -386,6 +502,8 @@ const server = Bun.serve({
position: getCurrentPosition(room),
},
chatHistory: room.chatHistory.slice(-50),
+ readyCheck: readyCheckState(room),
+ fightPoll: fightPollState(room),
})
);
@@ -400,6 +518,7 @@ const server = Bun.serve({
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} rejoined`);
+ if (room.readyCheck) broadcastReadyCheck(room);
break;
}
}
@@ -411,6 +530,11 @@ const server = Bun.serve({
const room = rooms.get(roomCode);
if (!room) return;
+ if (room.users.get(username) !== (ws as unknown as WebSocket)) return;
+ if (room.readyCheck) {
+ room.readyCheck.ready.delete(username);
+ broadcastReadyCheck(room);
+ }
// Don't remove immediately — give a 90s grace period for reconnection
const disconnectKey = `${roomCode}:${username}`;
@@ -425,6 +549,15 @@ const server = Bun.serve({
const currentWs = currentRoom.users.get(username);
if (currentWs === (ws as unknown as WebSocket)) {
currentRoom.users.delete(username);
+ if (currentRoom.fightPoll) {
+ currentRoom.fightPoll.votes.delete(username);
+ broadcastToRoom(currentRoom, fightPollState(currentRoom));
+ }
+ if (currentRoom.readyCheck) {
+ currentRoom.readyCheck.ready.delete(username);
+ if (currentRoom.readyCheck.requestedBy === username) currentRoom.readyCheck = undefined;
+ broadcastReadyCheck(currentRoom);
+ }
console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`);
if (currentRoom.users.size === 0) {