From 01ee8dc1fb7312a23e11fc8749e6a605e3de1486 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 29 May 2026 23:14:48 -0400 Subject: [PATCH] =?UTF-8?q?my-sea:=20async=20witness=20=E2=80=94=20spectat?= =?UTF-8?q?ors=20see=20the=20owner's=20draw=20land=20live=20via=20WS,=20no?= =?UTF-8?q?=20refresh=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bud watching @owner's sea now sees each card appear in real time instead of having to refresh. Follows the epic RoomConsumer broadcast pattern (view -> group_send -> consumer handler -> send_json), keyed on the owner (mysea_) since my-sea has no Room. - apps/gameboard/consumers.py: MySeaSpectateConsumer — read-only WS; membership gate matches voice (owner OR present invitee: ACCEPTED + deposited + not left). Relays a `sea_draw` event carrying the owner's full hand. - apps/gameboard/routing.py + core/asgi.py: ws/my-sea-spectate//. - gameboard/views.py: _notify_sea_draw(owner_id, hand) — best-effort, guarded group_send so a down/missing channel layer can't break the solo draw. Fired from my_sea_lock (both the create + the mid-draw-upsert branch) and from my_sea_delete (empty hand -> clears the spectators' cross). - my_sea_visit.html: a WS listener fills the cross live — SeaDeal.register(card, '.sea-pos-'+pos, isLevity) reuses _fillSlot (incl. the --rank-long squeeze) + seeds the slot clickable into the stage; a DEL re-empties cleared slots. Capped reconnect for transient blips. Tests: 5 channels ITs (owner/present-invitee connect + receive; unauth / stranger / accepted-not-present rejected); +2 view ITs (lock broadcasts owner+ hand; lock still 200s when the broadcast raises). Client fill needs live two-party verification on staging (Redis up). Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/gameboard/consumers.py | 51 ++++++++++++ src/apps/gameboard/routing.py | 9 +++ .../integrated/test_spectate_consumer.py | 81 +++++++++++++++++++ .../gameboard/tests/integrated/test_views.py | 25 ++++++ src/apps/gameboard/views.py | 22 +++++ src/core/asgi.py | 2 + .../apps/gameboard/my_sea_visit.html | 64 +++++++++++++++ 7 files changed, 254 insertions(+) create mode 100644 src/apps/gameboard/consumers.py create mode 100644 src/apps/gameboard/routing.py create mode 100644 src/apps/gameboard/tests/integrated/test_spectate_consumer.py diff --git a/src/apps/gameboard/consumers.py b/src/apps/gameboard/consumers.py new file mode 100644 index 0000000..53e3f34 --- /dev/null +++ b/src/apps/gameboard/consumers.py @@ -0,0 +1,51 @@ +"""My-Sea spectate WebSocket — pushes the owner's draw to watching invitees so +they witness each card land WITHOUT refreshing (user-spec 2026-05-29). + +Mirrors the epic `RoomConsumer` broadcast pattern (view → `group_send` → +consumer handler → `send_json`), but keyed on the owner (`mysea_`) +since my-sea has no Room. Read-only: clients never send, they only receive a +`sea_draw` event carrying the owner's full current hand; `my-sea-seats.js` / +sea.js fill the cross from it. Membership gate matches the voice consumer — the +owner, or a present invitee (deposited, not left). +""" + +from channels.db import database_sync_to_async +from channels.generic.websocket import AsyncJsonWebsocketConsumer + + +class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer): + async def connect(self): + self.owner_id = self.scope["url_route"]["kwargs"]["owner_id"] + self.user = self.scope.get("user") + self.group = None + if not (self.user and getattr(self.user, "is_authenticated", False)): + await self.close() + return + if not await self._can_watch(): + await self.close() + return + self.group = f"mysea_{self.owner_id}" + await self.channel_layer.group_add(self.group, self.channel_name) + await self.accept() + + async def disconnect(self, code): + if self.group: + await self.channel_layer.group_discard(self.group, self.channel_name) + + # ── room-group fan-out handler ────────────────────────────────────── + async def sea_draw(self, event): + """Relay the owner's hand to the watching client.""" + await self.send_json({"type": "sea_draw", "hand": event.get("hand", [])}) + + # ── membership gate ───────────────────────────────────────────────── + @database_sync_to_async + def _can_watch(self): + """The owner OR a present invitee (ACCEPTED + deposited + not left).""" + if str(self.user.id) == str(self.owner_id): + return True + from apps.gameboard.models import SeaInvite + return SeaInvite.objects.filter( + owner_id=self.owner_id, invitee=self.user, + status=SeaInvite.ACCEPTED, + token_deposited_at__isnull=False, left_at__isnull=True, + ).exists() diff --git a/src/apps/gameboard/routing.py b/src/apps/gameboard/routing.py new file mode 100644 index 0000000..87dfcac --- /dev/null +++ b/src/apps/gameboard/routing.py @@ -0,0 +1,9 @@ +from django.urls import path + +from . import consumers + + +websocket_urlpatterns = [ + path("ws/my-sea-spectate//", + consumers.MySeaSpectateConsumer.as_asgi()), +] diff --git a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py new file mode 100644 index 0000000..e52b8b0 --- /dev/null +++ b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py @@ -0,0 +1,81 @@ +"""Channels ITs for MySeaSpectateConsumer — the async-witness WS that pushes +the owner's draw to watching invitees (user-spec 2026-05-29). + +Same shape as the voice consumer tests: scope injected directly, in-memory +channel layer, `@tag("channels")` (excluded from the default run). +""" + +from channels.db import database_sync_to_async +from channels.testing.websocket import WebsocketCommunicator +from django.contrib.auth.models import AnonymousUser +from django.test import TransactionTestCase, override_settings, tag +from django.utils import timezone + +from apps.gameboard.consumers import MySeaSpectateConsumer +from apps.gameboard.models import SeaInvite +from apps.lyric.models import User + + +TEST_CHANNEL_LAYERS = { + "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"}, +} + + +@tag("channels") +@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS) +class MySeaSpectateConsumerTest(TransactionTestCase): + def setUp(self): + self.owner = User.objects.create(email="owner@test.io", username="discoman") + self.bud = User.objects.create(email="bud@test.io", username="budster") + + def _comm(self, user): + comm = WebsocketCommunicator( + MySeaSpectateConsumer.as_asgi(), + f"/ws/my-sea-spectate/{self.owner.id}/", + ) + comm.scope["user"] = user + comm.scope["url_route"] = {"kwargs": {"owner_id": self.owner.id}} + return comm + + def _invite(self, present): + return SeaInvite.objects.create( + owner=self.owner, invitee=self.bud, invitee_email=self.bud.email, + status=SeaInvite.ACCEPTED, accepted_at=timezone.now(), + token_deposited_at=timezone.now() if present else None, + ) + + async def test_owner_can_watch(self): + comm = self._comm(self.owner) + connected, _ = await comm.connect() + self.assertTrue(connected) + await comm.disconnect() + + async def test_unauthenticated_is_rejected(self): + comm = self._comm(AnonymousUser()) + connected, _ = await comm.connect() + self.assertFalse(connected) + + async def test_stranger_is_rejected(self): + comm = self._comm(self.bud) # no invite + connected, _ = await comm.connect() + self.assertFalse(connected) + + async def test_accepted_but_not_present_is_rejected(self): + await database_sync_to_async(self._invite)(present=False) + comm = self._comm(self.bud) + connected, _ = await comm.connect() + self.assertFalse(connected) + + async def test_present_invitee_receives_the_owners_hand(self): + await database_sync_to_async(self._invite)(present=True) + comm = self._comm(self.bud) + connected, _ = await comm.connect() + self.assertTrue(connected) + from channels.layers import get_channel_layer + hand = [{"position": "lay", "card_id": 1, + "reversed": False, "polarity": "gravity"}] + await get_channel_layer().group_send( + f"mysea_{self.owner.id}", {"type": "sea_draw", "hand": hand}) + msg = await comm.receive_json_from() + self.assertEqual(msg, {"type": "sea_draw", "hand": hand}) + await comm.disconnect() diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index 2d26ab6..63c2c04 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1860,6 +1860,31 @@ class MySeaLockHandViewTest(TestCase): response = self.client.get(self.url) self.assertEqual(response.status_code, 405) + def test_lock_broadcasts_hand_to_spectators(self): + # The owner's draw pushes the full hand to watching invitees (async + # witness, 2026-05-29) — my_sea_lock calls _notify_sea_draw(owner, hand). + import json + from unittest.mock import patch + payload = self._build_payload() + with patch("apps.gameboard.views._notify_sea_draw") as mock_notify: + self.client.post(self.url, data=json.dumps(payload), + content_type="application/json") + mock_notify.assert_called_once() + owner_id, hand = mock_notify.call_args[0] + self.assertEqual(owner_id, self.user.id) + self.assertEqual(hand, payload["hand"]) + + def test_lock_succeeds_even_when_the_broadcast_fails(self): + # The broadcast is best-effort — a down/missing channel layer must NOT + # break the solo draw save (guarded in _notify_sea_draw). + import json + from unittest.mock import patch + with patch("channels.layers.get_channel_layer", + side_effect=Exception("redis down")): + resp = self.client.post(self.url, data=json.dumps(self._build_payload()), + content_type="application/json") + self.assertEqual(resp.status_code, 200) + def test_lock_post_creates_my_sea_draw_for_user(self): import json from apps.gameboard.models import MySeaDraw diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index e674fe5..c07803d 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -450,6 +450,25 @@ def _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url): return payload +def _notify_sea_draw(owner_id, hand): + """Best-effort push of the owner's current hand to watching invitees (the + my-sea spectate WS, `mysea_`) so they witness each card land + without refreshing. Guarded — a missing/down channel layer must never break + the solo draw, since the spectate is an enhancement, not a hard dependency.""" + try: + from asgiref.sync import async_to_sync + from channels.layers import get_channel_layer + layer = get_channel_layer() + if layer is None: + return + async_to_sync(layer.group_send)( + f"mysea_{owner_id}", + {"type": "sea_draw", "hand": hand}, + ) + except Exception: + pass + + @login_required(login_url="/") @require_POST def my_sea_lock(request): @@ -512,6 +531,7 @@ def my_sea_lock(request): existing.paid_through_at = None update_fields.append("paid_through_at") existing.save(update_fields=update_fields) + _notify_sea_draw(request.user.id, existing.hand) # live to spectators return JsonResponse({ "ok": True, "next_free_draw_at": ( @@ -549,6 +569,7 @@ def my_sea_lock(request): significator_id=sig_id, significator_reversed=request.user.significator_reversed, ) + _notify_sea_draw(request.user.id, draw.hand) # live to spectators # Append the @taxman ledger entry + spawn the Brief. Response carries # the Brief payload so the picker IIFE can surface the banner in-place # w.o. a page reload — same affordance the prior in-template @@ -584,6 +605,7 @@ def my_sea_delete(request): if draw is not None: draw.hand = [] draw.save(update_fields=["hand"]) + _notify_sea_draw(request.user.id, []) # clear the spectators' cross return HttpResponse(status=204) diff --git a/src/core/asgi.py b/src/core/asgi.py index dd49130..b27d07a 100644 --- a/src/core/asgi.py +++ b/src/core/asgi.py @@ -5,6 +5,7 @@ from channels.routing import ProtocolTypeRouter, URLRouter from channels.auth import AuthMiddlewareStack import apps.epic.routing +import apps.gameboard.routing import apps.voice.routing @@ -15,6 +16,7 @@ application = ProtocolTypeRouter({ 'websocket': AuthMiddlewareStack( URLRouter( apps.epic.routing.websocket_urlpatterns + + apps.gameboard.routing.websocket_urlpatterns + apps.voice.routing.websocket_urlpatterns ) ), diff --git a/src/templates/apps/gameboard/my_sea_visit.html b/src/templates/apps/gameboard/my_sea_visit.html index d131c35..ded7aac 100644 --- a/src/templates/apps/gameboard/my_sea_visit.html +++ b/src/templates/apps/gameboard/my_sea_visit.html @@ -132,6 +132,70 @@ } }()); + {# Async witness — a WS to `mysea_` pushes the owner's hand as each #} + {# card lands; SeaDeal.register fills the cross slot (+ seeds it clickable) #} + {# live, no refresh. A DEL (empty hand) re-empties the cleared slots. #} + {% endif %}