Sig-select countdown reaches a CARTE owner in either polarity room — TDD
The 12s flashing #id_take_sig_btn never appeared for a solo CARTE gamer: the countdown is broadcast only to cursors_<room>_<polarity>, but RoomConsumer subscribed the WS to a single cursor group chosen from an arbitrary .first() seat and ignored the acting ?seat. A CARTE owner holds seats in BOTH polarities, so whenever he was completing the polarity his WS wasn't subscribed to, the countdown_start event silently missed him (the sigs still committed server-side via the timer — hence 'works but no visual'). Fix: carry the acting ?seat on the WS URL (room.js) and resolve the cursor group from it — the ?seat owned-slot override, else the role-canonical seat (PC-first), matching the overlay's seat resolution. Single-seat gamers are unaffected (one seat → canonical == .first()). New channels test CarteCursorGroupTest: acting a gravity seat (?seat) receives the gravity countdown; no-seat receives the canonical levity countdown. Full epic channels suite (7) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,10 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
self.cursor_group = None
|
||||
user = self.scope.get("user")
|
||||
if user and user.is_authenticated:
|
||||
seat = await self._get_seat(user)
|
||||
# CARTE owners hold seats in both polarities; subscribe to the
|
||||
# cursor group of the seat they're ACTING AS (the ?seat carried on
|
||||
# the WS URL), so the per-polarity countdown reaches the right room.
|
||||
seat = await self._get_seat(user, self._seat_param())
|
||||
if seat:
|
||||
if seat.role in LEVITY_ROLES:
|
||||
self.cursor_group = f"cursors_{self.room_id}_levity"
|
||||
@@ -49,10 +52,33 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
},
|
||||
)
|
||||
|
||||
def _seat_param(self):
|
||||
"""The ?seat=N value off the WS query string, or None."""
|
||||
from urllib.parse import parse_qs
|
||||
qs = parse_qs(self.scope.get("query_string", b"").decode())
|
||||
values = qs.get("seat")
|
||||
return values[0] if values else None
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_seat(self, user):
|
||||
def _get_seat(self, user, seat_param=None):
|
||||
"""The seat the viewer is acting as: the ?seat owned-slot override, else
|
||||
the role-canonical seat (PC→NC→EC→SC→AC→BC first) — matching the
|
||||
overlay's seat resolution so the WS cursor group agrees with the page."""
|
||||
from apps.epic.models import TableSeat
|
||||
return TableSeat.objects.filter(room_id=self.room_id, gamer=user).first()
|
||||
seats = list(TableSeat.objects.filter(room_id=self.room_id, gamer=user))
|
||||
if not seats:
|
||||
return None
|
||||
if seat_param:
|
||||
try:
|
||||
n = int(seat_param)
|
||||
except (TypeError, ValueError):
|
||||
n = None
|
||||
if n is not None:
|
||||
override = next((s for s in seats if s.slot_number == n), None)
|
||||
if override:
|
||||
return override
|
||||
order = {"PC": 0, "NC": 1, "EC": 2, "SC": 3, "AC": 4, "BC": 5}
|
||||
return min(seats, key=lambda s: order.get(s.role, 99))
|
||||
|
||||
async def gate_update(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
@@ -112,7 +112,12 @@
|
||||
|
||||
const roomId = roomPage.dataset.roomId;
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
||||
// Carry the acting ?seat (CARTE per-seat sig) so the consumer subscribes to
|
||||
// the cursor group of the polarity room the gamer is currently in — else a
|
||||
// multi-seat owner never receives the other polarity's countdown.
|
||||
const seatParam = new URLSearchParams(window.location.search).get('seat');
|
||||
const wsSeat = seatParam ? `?seat=${encodeURIComponent(seatParam)}` : '';
|
||||
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/${wsSeat}`);
|
||||
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
|
||||
@@ -336,3 +336,63 @@ class SigHoverConsumerTest(TransactionTestCase):
|
||||
self.assertTrue(msg["reserved"])
|
||||
|
||||
await nc_comm.disconnect()
|
||||
|
||||
|
||||
@tag('channels')
|
||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||
class CarteCursorGroupTest(TransactionTestCase):
|
||||
"""A CARTE owner holds seats in BOTH polarities. The WS must subscribe to
|
||||
the cursor group of the seat he is ACTING AS (the ?seat carried on the WS
|
||||
URL), so the polarity-room countdown numeral (broadcast to
|
||||
cursors_<room>_<polarity>) reaches him in whichever room he's completing.
|
||||
Regression: the consumer keyed the cursor group off an arbitrary .first()
|
||||
seat and ignored ?seat, so a CARTE owner acting in the other polarity never
|
||||
received countdown_start — the 12s flashing button never appeared."""
|
||||
|
||||
async def _make_communicator(self, user, room, seat_param=None):
|
||||
client = Client()
|
||||
await database_sync_to_async(client.force_login)(user)
|
||||
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||
path = f"/ws/room/{room.id}/"
|
||||
if seat_param is not None:
|
||||
path += f"?seat={seat_param}"
|
||||
comm = WebsocketCommunicator(
|
||||
application, path,
|
||||
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||
)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
return comm
|
||||
|
||||
async def _carte_room(self):
|
||||
owner = await database_sync_to_async(User.objects.create)(email="disco@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="Carte", owner=owner)
|
||||
# CARTE owner holds a levity seat (PC, slot 1) and a gravity seat (EC, slot 3).
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=owner, slot_number=1, role="PC")
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=owner, slot_number=3, role="EC")
|
||||
return owner, room
|
||||
|
||||
async def test_acting_gravity_seat_receives_gravity_countdown(self):
|
||||
owner, room = await self._carte_room()
|
||||
comm = await self._make_communicator(owner, room, seat_param=3) # EC = gravity
|
||||
await get_channel_layer().group_send(
|
||||
f"cursors_{room.id}_gravity",
|
||||
{"type": "countdown_start", "polarity": "gravity", "seconds": 12},
|
||||
)
|
||||
msg = await comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "countdown_start")
|
||||
self.assertEqual(msg["polarity"], "gravity")
|
||||
await comm.disconnect()
|
||||
|
||||
async def test_default_no_seat_receives_canonical_levity_countdown(self):
|
||||
owner, room = await self._carte_room()
|
||||
comm = await self._make_communicator(owner, room) # no ?seat → canonical PC = levity
|
||||
await get_channel_layer().group_send(
|
||||
f"cursors_{room.id}_levity",
|
||||
{"type": "countdown_start", "polarity": "levity", "seconds": 12},
|
||||
)
|
||||
msg = await comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["polarity"], "levity")
|
||||
await comm.disconnect()
|
||||
|
||||
Reference in New Issue
Block a user