Sig-select countdown reaches a CARTE owner in either polarity room — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

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:
Disco DeDisco
2026-06-05 02:35:04 -04:00
parent c2b244d796
commit e10f0f3939
3 changed files with 95 additions and 4 deletions

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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()