Merge main into PR #291 and resolve scoreboard phase conflicts
This commit is contained in:
@@ -1,26 +1,26 @@
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.exceptions import InvalidChannelLayerError
|
||||
from channels.layers import get_channel_layer
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
|
||||
try:
|
||||
from redis.exceptions import ConnectionError as RedisConnectionError
|
||||
except Exception: # pragma: no cover - optional dependency in local/test runtimes
|
||||
RedisConnectionError = RuntimeError
|
||||
|
||||
|
||||
async def broadcast_phase_event(session_code: str, event_type: str, payload: dict) -> None:
|
||||
"""Send a phase event to all WebSocket clients connected to a game session."""
|
||||
try:
|
||||
channel_layer = get_channel_layer()
|
||||
except InvalidChannelLayerError:
|
||||
return
|
||||
|
||||
if channel_layer is None:
|
||||
return
|
||||
|
||||
group_name = f"game_{session_code.upper()}"
|
||||
try:
|
||||
if channel_layer is None:
|
||||
return
|
||||
group_name = f"game_{session_code.upper()}"
|
||||
await channel_layer.group_send(
|
||||
group_name,
|
||||
{
|
||||
"type": "phase_event",
|
||||
"payload": {"type": event_type, **payload},
|
||||
"type": "phase.event",
|
||||
"event_type": event_type,
|
||||
"payload": payload,
|
||||
},
|
||||
)
|
||||
except (InvalidChannelLayerError, RedisConnectionError):
|
||||
|
||||
61
realtime/consumers.py
Normal file
61
realtime/consumers.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import json
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
|
||||
from fupogfakta.models import Player
|
||||
|
||||
|
||||
class GameConsumer(AsyncJsonWebsocketConsumer):
|
||||
"""
|
||||
WebSocket consumer for a game session.
|
||||
|
||||
URL: ws/game/<session_code>/
|
||||
|
||||
Query params:
|
||||
- session_token: player session token (players only)
|
||||
- role=host: skip token check for host in MVP
|
||||
"""
|
||||
|
||||
async def connect(self):
|
||||
self.session_code = self.scope["url_route"]["kwargs"]["session_code"].upper()
|
||||
self.group_name = f"game_{self.session_code}"
|
||||
|
||||
query_string = self.scope.get("query_string", b"").decode()
|
||||
params = parse_qs(query_string)
|
||||
|
||||
role = params.get("role", [None])[0]
|
||||
session_token = params.get("session_token", [None])[0]
|
||||
|
||||
if role != "host":
|
||||
if not session_token:
|
||||
await self.close(code=4001)
|
||||
return
|
||||
|
||||
try:
|
||||
self.player = await Player.objects.aget(
|
||||
session_token=session_token,
|
||||
session__code=self.session_code,
|
||||
)
|
||||
except Player.DoesNotExist:
|
||||
await self.close(code=4003)
|
||||
return
|
||||
else:
|
||||
self.player = None
|
||||
|
||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
if hasattr(self, "group_name"):
|
||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||
|
||||
async def receive_json(self, content, **kwargs):
|
||||
if content.get("type") == "ping":
|
||||
await self.send_json({"type": "pong"})
|
||||
|
||||
# --- Group message handlers ---
|
||||
|
||||
async def phase_event(self, event):
|
||||
"""Forward any phase_event broadcast to the WebSocket client."""
|
||||
await self.send_json(event["payload"])
|
||||
7
realtime/routing.py
Normal file
7
realtime/routing.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.urls import re_path
|
||||
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r"^ws/game/(?P<session_code>[A-Z0-9]{4,8})/$", consumers.GameConsumer.as_asgi()),
|
||||
]
|
||||
@@ -1,10 +1,21 @@
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from channels.exceptions import InvalidChannelLayerError
|
||||
from django.test import SimpleTestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
|
||||
try:
|
||||
from channels.testing import WebsocketCommunicator
|
||||
except Exception: # pragma: no cover - optional test dependency
|
||||
WebsocketCommunicator = None
|
||||
|
||||
from fupogfakta.models import GameSession, Player
|
||||
from partyhub.asgi import application
|
||||
from realtime.broadcast import broadcast_phase_event, sync_broadcast_phase_event
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class BroadcastPhaseEventTests(SimpleTestCase):
|
||||
@patch("realtime.broadcast.get_channel_layer", return_value=None)
|
||||
@@ -25,3 +36,67 @@ class BroadcastPhaseEventTests(SimpleTestCase):
|
||||
sync_broadcast_phase_event("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
|
||||
|
||||
sender.assert_called_once_with("ABCD", "phase.scoreboard", {"phase": "scoreboard"})
|
||||
|
||||
|
||||
@unittest.skipIf(WebsocketCommunicator is None, "channels.testing dependencies unavailable")
|
||||
class GameConsumerConnectTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="host", password="pw")
|
||||
self.session = GameSession.objects.create(host=self.user, code="AABBCC")
|
||||
self.player = Player.objects.create(session=self.session, nickname="Tester")
|
||||
|
||||
async def test_player_connect_and_ping(self):
|
||||
token = self.player.session_token
|
||||
communicator = WebsocketCommunicator(
|
||||
application,
|
||||
f"/ws/game/AABBCC/?session_token={token}",
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
await communicator.send_json_to({"type": "ping"})
|
||||
response = await communicator.receive_json_from()
|
||||
self.assertEqual(response["type"], "pong")
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_connect_without_token_rejected(self):
|
||||
communicator = WebsocketCommunicator(application, "/ws/game/AABBCC/")
|
||||
connected, code = await communicator.connect()
|
||||
self.assertFalse(connected)
|
||||
self.assertEqual(code, 4001)
|
||||
|
||||
async def test_connect_invalid_token_rejected(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
application,
|
||||
"/ws/game/AABBCC/?session_token=invalid-token",
|
||||
)
|
||||
connected, code = await communicator.connect()
|
||||
self.assertFalse(connected)
|
||||
self.assertEqual(code, 4003)
|
||||
|
||||
async def test_host_connect_without_token(self):
|
||||
communicator = WebsocketCommunicator(
|
||||
application,
|
||||
"/ws/game/AABBCC/?role=host",
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
await communicator.disconnect()
|
||||
|
||||
async def test_broadcast_reaches_connected_client(self):
|
||||
token = self.player.session_token
|
||||
communicator = WebsocketCommunicator(
|
||||
application,
|
||||
f"/ws/game/AABBCC/?session_token={token}",
|
||||
)
|
||||
connected, _ = await communicator.connect()
|
||||
self.assertTrue(connected)
|
||||
|
||||
await broadcast_phase_event("AABBCC", "phase.test_event", {"hello": "world"})
|
||||
|
||||
message = await communicator.receive_json_from(timeout=2)
|
||||
self.assertEqual(message["type"], "phase.test_event")
|
||||
self.assertEqual(message["hello"], "world")
|
||||
|
||||
await communicator.disconnect()
|
||||
|
||||
Reference in New Issue
Block a user