my-sea: async witness — spectators see the owner's draw land live via WS, no refresh — TDD
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_<owner>) 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/<owner_id>/. - 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 <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
src/apps/gameboard/consumers.py
Normal file
51
src/apps/gameboard/consumers.py
Normal file
@@ -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_<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).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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()
|
||||||
9
src/apps/gameboard/routing.py
Normal file
9
src/apps/gameboard/routing.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path("ws/my-sea-spectate/<uuid:owner_id>/",
|
||||||
|
consumers.MySeaSpectateConsumer.as_asgi()),
|
||||||
|
]
|
||||||
@@ -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()
|
||||||
@@ -1860,6 +1860,31 @@ class MySeaLockHandViewTest(TestCase):
|
|||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.status_code, 405)
|
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):
|
def test_lock_post_creates_my_sea_draw_for_user(self):
|
||||||
import json
|
import json
|
||||||
from apps.gameboard.models import MySeaDraw
|
from apps.gameboard.models import MySeaDraw
|
||||||
|
|||||||
@@ -450,6 +450,25 @@ def _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url):
|
|||||||
return payload
|
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_<owner_id>`) 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="/")
|
@login_required(login_url="/")
|
||||||
@require_POST
|
@require_POST
|
||||||
def my_sea_lock(request):
|
def my_sea_lock(request):
|
||||||
@@ -512,6 +531,7 @@ def my_sea_lock(request):
|
|||||||
existing.paid_through_at = None
|
existing.paid_through_at = None
|
||||||
update_fields.append("paid_through_at")
|
update_fields.append("paid_through_at")
|
||||||
existing.save(update_fields=update_fields)
|
existing.save(update_fields=update_fields)
|
||||||
|
_notify_sea_draw(request.user.id, existing.hand) # live to spectators
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"next_free_draw_at": (
|
"next_free_draw_at": (
|
||||||
@@ -549,6 +569,7 @@ def my_sea_lock(request):
|
|||||||
significator_id=sig_id,
|
significator_id=sig_id,
|
||||||
significator_reversed=request.user.significator_reversed,
|
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
|
# Append the @taxman ledger entry + spawn the Brief. Response carries
|
||||||
# the Brief payload so the picker IIFE can surface the banner in-place
|
# the Brief payload so the picker IIFE can surface the banner in-place
|
||||||
# w.o. a page reload — same affordance the prior in-template
|
# 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:
|
if draw is not None:
|
||||||
draw.hand = []
|
draw.hand = []
|
||||||
draw.save(update_fields=["hand"])
|
draw.save(update_fields=["hand"])
|
||||||
|
_notify_sea_draw(request.user.id, []) # clear the spectators' cross
|
||||||
return HttpResponse(status=204)
|
return HttpResponse(status=204)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from channels.routing import ProtocolTypeRouter, URLRouter
|
|||||||
from channels.auth import AuthMiddlewareStack
|
from channels.auth import AuthMiddlewareStack
|
||||||
|
|
||||||
import apps.epic.routing
|
import apps.epic.routing
|
||||||
|
import apps.gameboard.routing
|
||||||
import apps.voice.routing
|
import apps.voice.routing
|
||||||
|
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ application = ProtocolTypeRouter({
|
|||||||
'websocket': AuthMiddlewareStack(
|
'websocket': AuthMiddlewareStack(
|
||||||
URLRouter(
|
URLRouter(
|
||||||
apps.epic.routing.websocket_urlpatterns
|
apps.epic.routing.websocket_urlpatterns
|
||||||
|
+ apps.gameboard.routing.websocket_urlpatterns
|
||||||
+ apps.voice.routing.websocket_urlpatterns
|
+ apps.voice.routing.websocket_urlpatterns
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -132,6 +132,70 @@
|
|||||||
}
|
}
|
||||||
}());
|
}());
|
||||||
</script>
|
</script>
|
||||||
|
{# Async witness — a WS to `mysea_<owner>` 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. #}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (!('WebSocket' in window)) return;
|
||||||
|
var deckEl = document.getElementById('id_my_sea_deck');
|
||||||
|
var deck = {};
|
||||||
|
try { deck = JSON.parse((deckEl && deckEl.textContent) || '{}'); } catch (e) {}
|
||||||
|
var byId = {};
|
||||||
|
(deck.levity || []).concat(deck.gravity || []).forEach(function (c) { byId[c.id] = c; });
|
||||||
|
|
||||||
|
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;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
// Re-empty any slot the owner cleared (DEL → empty-hand broadcast).
|
||||||
|
cross.querySelectorAll('.sea-card-slot.sea-card-slot--filled').forEach(function (slot) {
|
||||||
|
var pos = slot.dataset.posKey;
|
||||||
|
if (pos && !inHand[pos]) {
|
||||||
|
var crossing = slot.classList.contains('sea-card-slot--crossing');
|
||||||
|
slot.className = 'sea-card-slot sea-card-slot--empty' + (crossing ? ' sea-card-slot--crossing' : '');
|
||||||
|
slot.innerHTML = '';
|
||||||
|
slot.removeAttribute('data-card-id');
|
||||||
|
slot.removeAttribute('data-pos-key');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window._mySeaApplyHand = _applyHand; // test seam
|
||||||
|
|
||||||
|
var tries = 0;
|
||||||
|
function _connect() {
|
||||||
|
var scheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
var ws;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(scheme + '://' + window.location.host + '/ws/my-sea-spectate/{{ owner.id }}/');
|
||||||
|
} catch (e) { return; }
|
||||||
|
ws.onmessage = function (ev) {
|
||||||
|
var msg;
|
||||||
|
try { msg = JSON.parse(ev.data); } catch (e) { return; }
|
||||||
|
if (msg && msg.type === 'sea_draw') _applyHand(msg.hand);
|
||||||
|
};
|
||||||
|
// Brief capped reconnect for transient blips (no infinite loop if
|
||||||
|
// the spectate server/Redis is down).
|
||||||
|
ws.onclose = function () { if (tries++ < 5) setTimeout(_connect, 3000); };
|
||||||
|
}
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', _connect);
|
||||||
|
} else {
|
||||||
|
_connect();
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user