my-sea spectate: broadcast spread on modal-close + sequence the spectator's AUTO DRAW reveal — TDD

Two follow-ons to the spectate spread-sync, both over the `mysea_<owner>` 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 <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-30 00:45:00 -04:00
parent 9678d187b4
commit 7e876557aa
7 changed files with 213 additions and 20 deletions

View File

@@ -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_<owner_id>`)
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):

View File

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

View File

@@ -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."""

View File

@@ -15,6 +15,7 @@ urlpatterns = [
path('game-kit/deck/<int:deck_id>/', 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'),

View File

@@ -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": "<slug>"}`. 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):

View File

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

View File

@@ -190,31 +190,52 @@
}
window._mySeaRenderSeats = _renderSeats; // test seam
function _applyHand(hand) {
if (!window.SeaDeal || !window.SeaDeal.register) return;
var cross = document.querySelector('.my-sea-cross');
if (!cross) return;
var inHand = {};
(hand || []).forEach(function (e) {
if (!e || !e.position) return;
inHand[e.position] = true;
// 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 in the SAME tick so the
// card lands at its final opacity — matching the refreshed
// state — instead of racing the empty→filled opacity transition
// 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;
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