From 7e876557aa5109341c45449cd798436ebec22f6e Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 30 May 2026 00:45:00 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20spectate:=20broadcast=20spread=20on=20?= =?UTF-8?q?modal-close=20+=20sequence=20the=20spectator's=20AUTO=20DRAW=20?= =?UTF-8?q?reveal=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ons to the spectate spread-sync, both over the `mysea_` consumer: SPREAD ON MODAL-CLOSE — the spread only reached spectators piggy-backed on the first `sea_draw`, so a visitor sat on a stale layout until a card landed. The owner's SPREAD modal now broadcasts her chosen spread the moment she closes it (backdrop / Escape / guard-OK) — before any draw: - new hand-less `sea_spread` event: view `_notify_sea_spread` → consumer relay → visitor `_applySpread` (re-lays-out `[data-spread]` + re-captions, no hand touched). - new POST `/gameboard/my-sea/spread` the modal-close handler calls, guarded by `_lastSpread` so re-opening + closing without a change doesn't re-broadcast. When an active row has an EMPTY hand it also persists the spread onto the row (so a fresh spectator load lands right too) — stays within the "spread locks at first card" policy; never overwrites a drawn hand's spread. SEQUENCED AUTO DRAW — AUTO DRAW commits all six cards in ONE POST (navigate-away safety) → one `sea_draw` carrying the whole hand, so the spectator saw them pop in at once ("async as intended, but not in sequence"). The visitor's `_applyHand` now reveals only the freshly-added entries, one per ~420ms tick (in DRAW_ORDER, first immediately) — a lone manual-draw card still reveals instantly. Already- shown cards (`_isShown` by slot card-id) are left untouched, so a cumulative re-broadcast never re-animates. Coverage: - ITs: MySeaSpreadBroadcastViewTest — login/405/unknown-spread guards, broadcast call, empty-hand persist, no-overwrite-of-drawn-spread, broadcast-failure resilience. - channels: spectate consumer relays the hand-less `sea_spread` event. - Live-verified in Firefox: a 3-card hand fills 1 slot synchronously then the rest after the stagger; user visually confirmed the full deal sequence + the modal-close spread propagation. 311 gameboard ITs + 7 spectate channels green. Code architected by Disco DeDisco Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/gameboard/consumers.py | 14 ++-- .../integrated/test_spectate_consumer.py | 16 +++++ .../gameboard/tests/integrated/test_views.py | 64 +++++++++++++++++++ src/apps/gameboard/urls.py | 1 + src/apps/gameboard/views.py | 49 ++++++++++++++ src/templates/apps/gameboard/my_sea.html | 26 ++++++++ .../apps/gameboard/my_sea_visit.html | 63 +++++++++++++----- 7 files changed, 213 insertions(+), 20 deletions(-) diff --git a/src/apps/gameboard/consumers.py b/src/apps/gameboard/consumers.py index 148025f..5602e9d 100644 --- a/src/apps/gameboard/consumers.py +++ b/src/apps/gameboard/consumers.py @@ -3,10 +3,11 @@ 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). +since my-sea has no Room. Read-only: clients never send, they only receive +events — `sea_draw` (the owner's full current hand + spread), `sea_spread` (a +spread change on modal-close, no hand), and `sea_seats` (the presence ring); +`my-sea-seats.js` / sea.js apply them to the cross + hex. Membership gate matches +the voice consumer — the owner, or a present invitee (deposited, not left). """ from channels.db import database_sync_to_async @@ -47,6 +48,11 @@ class MySeaSpectateConsumer(AsyncJsonWebsocketConsumer): """Relay the seat ring (a deposit took / a BYE freed a seat).""" await self.send_json({"type": "sea_seats", "seats": event.get("seats", [])}) + async def sea_spread(self, event): + """Relay a spread change the moment the owner closes her SPREAD modal, so + the spectator re-lays-out BEFORE any card lands (no hand in this event).""" + await self.send_json({"type": "sea_spread", "spread": event.get("spread", "")}) + # ── membership gate ───────────────────────────────────────────────── @database_sync_to_async def _can_watch(self): diff --git a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py index 9317e27..e9cbf0d 100644 --- a/src/apps/gameboard/tests/integrated/test_spectate_consumer.py +++ b/src/apps/gameboard/tests/integrated/test_spectate_consumer.py @@ -86,6 +86,22 @@ class MySeaSpectateConsumerTest(TransactionTestCase): {"type": "sea_draw", "hand": hand, "spread": "escape-velocity"}) await comm.disconnect() + async def test_present_invitee_receives_spread_only_event(self): + # The owner closing her SPREAD modal pushes a hand-less `sea_spread` so + # the spectator re-lays-out before any card lands (2026-05-30). + 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 + await get_channel_layer().group_send( + f"mysea_{self.owner.id}", + {"type": "sea_spread", "spread": "mind-body-spirit"}) + msg = await comm.receive_json_from() + self.assertEqual( + msg, {"type": "sea_spread", "spread": "mind-body-spirit"}) + await comm.disconnect() + async def test_present_invitee_receives_seat_updates(self): await database_sync_to_async(self._invite)(present=True) comm = self._comm(self.bud) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index ab9f8ca..19aa90c 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -2138,6 +2138,70 @@ class MySeaLockHandViewTest(TestCase): self.assertEqual(draw.significator_id, self.target.id) +class MySeaSpreadBroadcastViewTest(TestCase): + """POST `/gameboard/my-sea/spread` — broadcasts the owner's chosen spread to + watching spectators on modal-close, before any card is drawn (2026-05-30).""" + + def setUp(self): + self.user = User.objects.create(email="spread@test.io") + self.client.force_login(self.user) + self.url = reverse("my_sea_spread") + + def _post(self, spread): + import json + return self.client.post( + self.url, data=json.dumps({"spread": spread}), + content_type="application/json") + + def test_requires_login(self): + self.client.logout() + self.assertEqual(self._post("mind-body-spirit").status_code, 302) + + def test_get_returns_405(self): + self.assertEqual(self.client.get(self.url).status_code, 405) + + def test_unknown_spread_rejected(self): + self.assertEqual(self._post("not-a-spread").status_code, 400) + + def test_valid_spread_broadcasts(self): + from unittest.mock import patch + with patch("apps.gameboard.views._notify_sea_spread") as mock_notify: + resp = self._post("mind-body-spirit") + self.assertEqual(resp.status_code, 200) + mock_notify.assert_called_once_with(self.user.id, "mind-body-spirit") + + def test_persists_spread_onto_empty_hand_row(self): + # A fresh/post-DEL row (empty hand) adopts the new spread so a spectator + # who loads fresh also lands on the right layout. + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=1, hand=[]) + self._post("desire-obstacle-solution") + self.assertEqual( + MySeaDraw.objects.get(user=self.user).spread, + "desire-obstacle-solution") + + def test_does_not_overwrite_a_drawn_hands_spread(self): + # Never clobbers a spread once cards exist (stays within the lock policy). + from apps.gameboard.models import MySeaDraw + MySeaDraw.objects.create( + user=self.user, spread="situation-action-outcome", + significator_id=1, + hand=[{"position": "lay", "card_id": 1, + "reversed": False, "polarity": "gravity"}]) + self._post("desire-obstacle-solution") + self.assertEqual( + MySeaDraw.objects.get(user=self.user).spread, + "situation-action-outcome") + + def test_succeeds_even_when_broadcast_fails(self): + from unittest.mock import patch + with patch("channels.layers.get_channel_layer", + side_effect=Exception("redis down")): + self.assertEqual(self._post("mind-body-spirit").status_code, 200) + + class MySeaDeleteDrawViewTest(TestCase): """Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND but preserves the row so the 24h quota window keeps running.""" diff --git a/src/apps/gameboard/urls.py b/src/apps/gameboard/urls.py index fb7631e..4331750 100644 --- a/src/apps/gameboard/urls.py +++ b/src/apps/gameboard/urls.py @@ -15,6 +15,7 @@ urlpatterns = [ path('game-kit/deck//', views.tarot_fan, name='tarot_fan'), path('my-sea/', views.my_sea, name='my_sea'), path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'), + path('my-sea/spread', views.my_sea_spread, name='my_sea_spread'), path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'), path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'), path('my-sea/insert', views.my_sea_insert_token, name='my_sea_insert_token'), diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 6bf6bb8..3ce46b0 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -476,6 +476,27 @@ def _notify_sea_draw(owner_id, hand, spread): pass +def _notify_sea_spread(owner_id, spread): + """Best-effort push of JUST the owner's chosen spread (no hand) to watching + invitees, so the spectator's cross re-lays-out + re-captions the moment the + owner closes her SPREAD modal — before any card is drawn (user-spec + 2026-05-30). Previously the spread only reached spectators piggy-backed on + the first `sea_draw`, so the visitor sat on a stale layout until a card + landed. Guarded, same as `_notify_sea_draw`.""" + 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_spread", "spread": spread}, + ) + except Exception: + pass + + def _my_sea_seats(owner): """Table-ring seat list for `owner`'s sea — owner 1C + present invitees 2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS). Each entry is @@ -653,6 +674,34 @@ def my_sea_lock(request): }) +@login_required(login_url="/") +@require_POST +def my_sea_spread(request): + """Broadcast the owner's chosen spread to watching spectators the moment she + closes the SPREAD modal — before any card is drawn (user-spec 2026-05-30). + + Body: JSON `{"spread": ""}`. No hand is touched. If an active draw row + exists with an EMPTY hand (fresh / post-DEL), the spread is persisted onto it + so a spectator who loads fresh ALSO lands on the right layout — this stays + within the existing "spread locks at first card" policy (a non-empty hand + never reaches here from the client, and we never overwrite a drawn spread). + + Returns 200 `{ok}` on success; 400 for a malformed / unknown spread.""" + try: + payload = json.loads(request.body.decode("utf-8") or "{}") + except json.JSONDecodeError: + return JsonResponse({"error": "invalid_json"}, status=400) + spread = payload.get("spread") + if spread not in HAND_SIZE_BY_SPREAD: + return JsonResponse({"error": "unknown_spread"}, status=400) + draw = active_draw_for(request.user) + if draw is not None and not draw.hand and draw.spread != spread: + draw.spread = spread + draw.save(update_fields=["spread"]) + _notify_sea_spread(request.user.id, spread) + return JsonResponse({"ok": True}) + + @login_required(login_url="/") @require_POST def my_sea_delete(request): diff --git a/src/templates/apps/gameboard/my_sea.html b/src/templates/apps/gameboard/my_sea.html index 14e9d86..8cdc045 100644 --- a/src/templates/apps/gameboard/my_sea.html +++ b/src/templates/apps/gameboard/my_sea.html @@ -1126,8 +1126,34 @@ function openModal() { modal.removeAttribute('hidden'); } + // On close, push the owner's CURRENT spread to watching spectators + // so their cross re-lays-out immediately — not only once the first + // card lands (user-spec 2026-05-30). Fires on backdrop / Escape / + // guard-OK close. Guarded by `_lastSpread` so re-opening + closing + // without a change doesn't re-broadcast. The select's change handler + // already mirrors the choice onto `.my-sea-cross[data-spread]`, so + // that's the live source of truth at close time. + var crossEl = document.querySelector('.my-sea-cross'); + var _lastSpread = crossEl ? crossEl.getAttribute('data-spread') : null; + function _broadcastSpread() { + if (!crossEl) return; + var spread = crossEl.getAttribute('data-spread'); + if (!spread || spread === _lastSpread) return; + _lastSpread = spread; + var m = document.cookie.match(/(?:^|; )csrftoken=([^;]+)/); + fetch('{% url "my_sea_spread" %}', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': m ? decodeURIComponent(m[1]) : '', + }, + body: JSON.stringify({ spread: spread }), + }).catch(function () { /* best-effort — spectate is an enhancement */ }); + } function closeModal() { modal.setAttribute('hidden', ''); + _broadcastSpread(); } seaBtn.addEventListener('click', function () { diff --git a/src/templates/apps/gameboard/my_sea_visit.html b/src/templates/apps/gameboard/my_sea_visit.html index 2d693d4..7799689 100644 --- a/src/templates/apps/gameboard/my_sea_visit.html +++ b/src/templates/apps/gameboard/my_sea_visit.html @@ -190,31 +190,52 @@ } window._mySeaRenderSeats = _renderSeats; // test seam + // Reveal a single hand entry on the cross (register + force --visible). + function _revealCard(cross, e) { + var card = byId[e.card_id]; + if (!card) return; + var c = {}; + for (var k in card) { if (Object.prototype.hasOwnProperty.call(card, k)) c[k] = card[k]; } + c.reversed = !!e.reversed; + window.SeaDeal.register(c, '.sea-pos-' + e.position, e.polarity === 'levity'); + // register's _fillSlot sets --filled (opacity:0) but not --visible + // (the owner adds that on stage-dismiss; the spectator has no modal). + // Add it here so the card lands at its final opacity — matching the + // refreshed state — instead of racing the empty→filled transition + // (the long-standing my_sea ease-in/ease-out glitch). + var cell = cross.querySelector('.sea-pos-' + e.position); + var filled = cell && cell.querySelector('.sea-card-slot--filled'); + if (filled) filled.classList.add('sea-card-slot--visible'); + } + // True when this exact card is already shown in its slot — so a + // cumulative re-broadcast doesn't re-animate cards already on the cross. + function _isShown(cross, e) { + var cell = cross.querySelector('.sea-pos-' + e.position); + var slot = cell && cell.querySelector('.sea-card-slot--filled'); + return !!(slot && String(slot.dataset.cardId) === String(e.card_id)); + } + + // Visitor witnesses cards land ONE AT A TIME. AUTO DRAW commits all six + // in a single POST (navigate-away safety) → one `sea_draw` carrying the + // whole hand; revealing them all in that tick made them pop in at once + // (user-reported 2026-05-30: "async as intended, but not in sequence"). + // So we stagger the freshly-added entries — already-shown cards stay put + // (a manual single-card add reveals immediately, fresh.length === 1). + var REVEAL_STAGGER_MS = 420; + var _revealTimer = null; function _applyHand(hand) { if (!window.SeaDeal || !window.SeaDeal.register) return; var cross = document.querySelector('.my-sea-cross'); if (!cross) return; + if (_revealTimer) { clearTimeout(_revealTimer); _revealTimer = null; } var inHand = {}; + var fresh = []; (hand || []).forEach(function (e) { if (!e || !e.position) return; inHand[e.position] = true; - var card = byId[e.card_id]; - if (!card) return; - var c = {}; - for (var k in card) { if (Object.prototype.hasOwnProperty.call(card, k)) c[k] = card[k]; } - c.reversed = !!e.reversed; - window.SeaDeal.register(c, '.sea-pos-' + e.position, e.polarity === 'levity'); - // register's _fillSlot sets --filled (opacity:0) but not - // --visible (the owner adds that on stage-dismiss; the - // spectator has no modal). Add it here in the SAME tick so the - // card lands at its final opacity — matching the refreshed - // state — instead of racing the empty→filled opacity transition - // (the long-standing my_sea ease-in/ease-out glitch). - var cell = cross.querySelector('.sea-pos-' + e.position); - var filled = cell && cell.querySelector('.sea-card-slot--filled'); - if (filled) filled.classList.add('sea-card-slot--visible'); + if (!_isShown(cross, e)) fresh.push(e); // queue only new cards }); - // Re-empty any slot the owner cleared (DEL → empty-hand broadcast). + // Re-empty any slot the owner cleared (DEL / spread switch). cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) { var pos = slot.dataset.posKey; if (pos && !inHand[pos]) { @@ -225,6 +246,15 @@ slot.removeAttribute('data-pos-key'); } }); + // Reveal fresh entries in draw order (the server stores the hand in + // DRAW_ORDER), first immediately then one per stagger tick. + var idx = 0; + function next() { + if (idx >= fresh.length) { _revealTimer = null; return; } + _revealCard(cross, fresh[idx++]); + _revealTimer = (idx < fresh.length) ? setTimeout(next, REVEAL_STAGGER_MS) : null; + } + next(); } window._mySeaApplyHand = _applyHand; // test seam @@ -240,6 +270,7 @@ try { msg = JSON.parse(ev.data); } catch (e) { return; } if (!msg) return; if (msg.type === 'sea_draw') { _applySpread(msg.spread); _applyHand(msg.hand); } + else if (msg.type === 'sea_spread') _applySpread(msg.spread); else if (msg.type === 'sea_seats') _renderSeats(msg.seats); }; // Brief capped reconnect for transient blips (no infinite loop if