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:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user