my-sea spectator Phase B: seat-2C occupancy + visitor token gate + one-shot seated glow + gear BYE — TDD

Phase B of the my-sea invite → spectator → voice blueprint. An ACCEPTED
invitee can watch the owner's my-sea read-only, deposit a token to occupy
seat 2C (opening a 24h voice window for Phase C), and BYE out. Owner's
my_sea.html is left structurally intact — the spectator gets a dedicated,
simpler my_sea_visit.html; the read-only draw reuses the existing
`latest_draw_slots` payload (no picker surgery).

- B1: my_sea_visit(owner_id) spectator view — 403 unless an ACCEPTED
  SeaInvite(owner, request.user); owner bounced to their own my_sea. Context
  forces owner-only controls off (sea_btn_active=False, read_only=True);
  renders the table hex (1C owner / 2C visitor) + owner draw read-only.
- B2: visitor gate — my_sea_visit_gate reuses my_sea_gate.html w. a
  spectator branch (titles the OWNER's Sea, INSERT posts to the visitor
  endpoint, bud-panel suppressed, gear NVM→visit + BYE). Single-step
  my_sea_visit_insert_token selects+debits the visitor's token (same
  priority chain) and records token_deposited_at + a 24h voice_until on the
  SeaInvite → seat 2C present. Center btn flips GATE VIEW → VIEW DRAW.
- B3: spectator gear BYE — my_sea_visit_leave sets status=LEFT, left_at,
  clears voice_until (frees 2C, ends voice), redirects /gameboard/.
  _my_sea_gear.html gains a `leave_url`-gated BYE below NVM (owner pages
  pass no leave_url, so unchanged).
- B-seat: one-shot "seated" glow per user-spec 2026-05-27 — new shared
  apps/gameboard/my-sea-seats.js: on first view (localStorage-gated by a
  per-occupancy data-seat-token) an occupied seat flares --terUser +
  --ninUser glow ~1.5s then settles to full-opacity --secUser (.fa-ban
  already swapped to .fa-circle-check). _room.scss adds .seated /
  .seat-just-seated + the my-sea-seat-flare keyframes (mirrors the room's
  .active→.role-confirmed handoff). Wired on BOTH the spectator page (load)
  and the owner page (load + on the FREE DRAW seat-1 transition).
  MySeaSeatsSpec.js Jasmine spec covers the gating + timed class removal.
- B5: MySeaSpectatorFlowTest FT — accept → visit → GATE VIEW → deposit →
  VIEW DRAW + seat 2C seated.

URLs: my-sea/visit/<uuid:owner_id>/ (+ /gate/, /insert, /leave). 470 IT/UT
green; spectator FT + full Jasmine suite green. Phase C (WebRTC mesh voice
+ coturn droplet) next — the 24h voice_until window set here drives it.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-27 13:35:00 -04:00
parent fb8563eed2
commit d0c39b51b6
15 changed files with 740 additions and 9 deletions

View File

@@ -0,0 +1,53 @@
// my-sea-seats.js — one-shot "seated" animation for the my-sea table hex.
//
// When a seat is occupied (owner draws → seat 1C; visitor deposits a token →
// seat 2C), the FIRST time a given viewer sees that occupancy the seat plays
// a one-shot glow: chair fills --terUser + glows --ninUsers for ~1.5s, then
// settles back to --secUser at FULL opacity (the steady `.seated` look), with
// `.fa-ban` already swapped for `.fa-circle-check` server-side. "First time"
// is tracked per-browser in localStorage, keyed on a stable per-occupancy
// token (`data-seat-token`) so a fresh draw / fresh invite re-animates but a
// reload does not. (Spec: user 2026-05-27.)
(function () {
'use strict';
var STORE_PREFIX = 'mysea-seat-seen:';
var GLOW_MS = 1500;
function alreadySeen(token) {
try {
return window.localStorage.getItem(STORE_PREFIX + token) === '1';
} catch (e) {
return false; // private mode / storage disabled → always animate
}
}
function markSeen(token) {
try { window.localStorage.setItem(STORE_PREFIX + token, '1'); }
catch (e) { /* no-op */ }
}
// Play the one-shot glow on a `.table-seat` element unless this viewer has
// already seen this exact occupancy. Safe to call on a seat with no token
// (always animates, never persists).
function playSeatGlow(seat) {
if (!seat) return;
var token = seat.getAttribute('data-seat-token');
if (token && alreadySeen(token)) return;
seat.classList.add('seat-just-seated');
window.setTimeout(function () {
seat.classList.remove('seat-just-seated');
}, GLOW_MS);
if (token) markSeen(token);
}
// Exposed so the owner's FREE DRAW flow can fire the same glow the moment
// seat 1C transitions to `.seated` client-side (my_sea.html).
window.playSeatGlow = playSeatGlow;
document.addEventListener('DOMContentLoaded', function () {
var seats = document.querySelectorAll('.table-seat.seated[data-seat-token]');
Array.prototype.forEach.call(seats, function (seat) {
playSeatGlow(seat);
});
});
}());

View File

@@ -0,0 +1,175 @@
"""ITs for the my-sea spectator (invitee) views — Phase B of
[[my-sea-invite-voice-blueprint]].
An ACCEPTED invitee can VISIT the owner's my-sea as a read-only spectator,
deposit a token at a visitor gate to occupy seat 2C (+ open a 24h voice
window), and BYE out (freeing the seat). All access is gated on an ACCEPTED
SeaInvite for (owner, request.user).
"""
from datetime import timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.gameboard.models import MySeaDraw, SeaInvite
from apps.lyric.models import Token, User
def _owner_with_sig(email="owner@test.io", username="discoman"):
owner = User.objects.create(email=email, username=username)
# A sig id is required to build a MySeaDraw; any int is fine for the
# spectator read-only path (no card lookup asserted here).
return owner
class MySeaVisitGuardTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
self.url = reverse("my_sea_visit", args=[self.owner.id])
def test_accepted_invitee_can_visit(self):
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 200)
def test_non_invitee_is_forbidden(self):
stranger = User.objects.create(email="x@test.io", username="x")
self.client.force_login(stranger)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_pending_invitee_is_forbidden(self):
self.invite.status = SeaInvite.PENDING
self.invite.save()
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_left_invitee_is_forbidden(self):
self.invite.status = SeaInvite.LEFT
self.invite.left_at = timezone.now()
self.invite.save()
self.client.force_login(self.bud)
self.assertEqual(self.client.get(self.url).status_code, 403)
def test_owner_visiting_own_sea_redirects_to_my_sea(self):
self.client.force_login(self.owner)
resp = self.client.get(self.url)
self.assertRedirects(resp, reverse("my_sea"), fetch_redirect_response=False)
class MySeaVisitContextTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
# Owner has drawn a hand → seat 1C is occupied + cards exist to view.
MySeaDraw.objects.create(
user=self.owner, spread="situation-action-outcome",
significator_id=1, hand=[
{"position": "lay", "card_id": 1, "reversed": False,
"polarity": "gravity"},
],
)
self.client.force_login(self.bud)
self.url = reverse("my_sea_visit", args=[self.owner.id])
def test_spectator_context_flags(self):
ctx = self.client.get(self.url).context
self.assertTrue(ctx["spectator"])
self.assertFalse(ctx["is_owner"])
self.assertEqual(ctx["owner"], self.owner)
self.assertEqual(ctx["sea_invite"], self.invite)
# Owner controls forced off on the spectator surface.
self.assertFalse(ctx["sea_btn_active"])
def test_not_present_shows_gate_view(self):
content = self.client.get(self.url).content.decode()
self.assertIn("GATE", content)
self.assertIn(reverse("my_sea_visit_gate", args=[self.owner.id]), content)
def test_present_shows_view_draw_and_seat2(self):
self.invite.token_deposited_at = timezone.now()
self.invite.voice_until = timezone.now() + timedelta(hours=24)
self.invite.save()
ctx = self.client.get(self.url).context
self.assertTrue(ctx["sea_invite"].is_present)
self.assertTrue(ctx["seat2_present"])
self.assertIn("VIEW", self.client.get(self.url).content.decode())
class MySeaVisitInsertTokenTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = SeaInvite.objects.create(
owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
self.url = reverse("my_sea_visit_insert_token", args=[self.owner.id])
def test_deposit_marks_present_and_opens_voice_window(self):
self.client.force_login(self.bud)
resp = self.client.post(self.url)
self.invite.refresh_from_db()
self.assertIsNotNone(self.invite.token_deposited_at)
self.assertIsNotNone(self.invite.voice_until)
self.assertTrue(self.invite.voice_active)
self.assertTrue(self.invite.is_present)
self.assertRedirects(
resp, reverse("my_sea_visit", args=[self.owner.id]),
fetch_redirect_response=False,
)
def test_non_invitee_cannot_deposit(self):
stranger = User.objects.create(email="x@test.io", username="x")
self.client.force_login(stranger)
self.assertEqual(self.client.post(self.url).status_code, 403)
def test_no_token_does_not_mark_present(self):
# Strip the visitor of every usable token.
self.bud.equipped_trinket = None
self.bud.save(update_fields=["equipped_trinket"])
Token.objects.filter(user=self.bud).delete()
self.client.force_login(self.bud)
self.client.post(self.url)
self.invite.refresh_from_db()
self.assertIsNone(self.invite.token_deposited_at)
self.assertFalse(self.invite.is_present)
class MySeaVisitLeaveTest(TestCase):
def setUp(self):
self.owner = _owner_with_sig()
self.bud = User.objects.create(email="bud@test.io", username="budster")
self.invite = 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(),
voice_until=timezone.now() + timedelta(hours=24),
)
self.url = reverse("my_sea_visit_leave", args=[self.owner.id])
def test_leave_sets_left_and_kills_voice(self):
self.client.force_login(self.bud)
resp = self.client.post(self.url)
self.invite.refresh_from_db()
self.assertEqual(self.invite.status, SeaInvite.LEFT)
self.assertIsNotNone(self.invite.left_at)
self.assertIsNone(self.invite.voice_until)
self.assertFalse(self.invite.is_present)
self.assertRedirects(
resp, reverse("gameboard"), fetch_redirect_response=False,
)
def test_non_invitee_cannot_leave(self):
stranger = User.objects.create(email="x@test.io", username="x")
self.client.force_login(stranger)
self.assertEqual(self.client.post(self.url).status_code, 403)

View File

@@ -25,6 +25,13 @@ urlpatterns = [
name='my_sea_invite_accept'),
path('my-sea/invite/decline/<int:invite_id>', views.my_sea_invite_decline,
name='my_sea_invite_decline'),
path('my-sea/visit/<uuid:owner_id>/', views.my_sea_visit, name='my_sea_visit'),
path('my-sea/visit/<uuid:owner_id>/gate/', views.my_sea_visit_gate,
name='my_sea_visit_gate'),
path('my-sea/visit/<uuid:owner_id>/insert', views.my_sea_visit_insert_token,
name='my_sea_visit_insert_token'),
path('my-sea/visit/<uuid:owner_id>/leave', views.my_sea_visit_leave,
name='my_sea_visit_leave'),
path('my-sea/brief/free-draw/dismiss', views.my_sea_dismiss_free_draw_brief,
name='my_sea_dismiss_free_draw_brief'),
path('my-sea/brief/paid-draw/dismiss', views.my_sea_dismiss_paid_draw_brief,

View File

@@ -865,6 +865,131 @@ def my_sea_invite_decline(request, invite_id):
return _redirect_to_invite_log(invite)
# ── Phase B — my-sea spectator (invitee) surfaces ───────────────────────────
def _accepted_visit_invite(owner, user):
"""The requester's ACCEPTED invite to spectate `owner`'s my-sea, or None.
The single access gate for every spectator surface — a token deposit
keeps the row ACCEPTED (presence derives from `token_deposited_at`); a
BYE flips it to LEFT, which closes the gate."""
from .models import SeaInvite
return SeaInvite.objects.filter(
owner=owner, invitee=user, status=SeaInvite.ACCEPTED,
).order_by("-created_at").first()
@login_required(login_url="/")
def my_sea_visit(request, owner_id):
"""Spectator view — an ACCEPTED invitee watches the owner's my-sea read-
only (Phase B of [[my-sea-invite-voice-blueprint]]). 403 unless an
ACCEPTED SeaInvite(owner, request.user) exists; the owner is bounced to
their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C =
this visitor once present) + the owner's draw read-only via
`latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's
hand — `sea_btn_active` forced False."""
from apps.lyric.models import User
owner = get_object_or_404(User, id=owner_id)
if owner == request.user:
return redirect("my_sea")
invite = _accepted_visit_invite(owner, request.user)
if invite is None:
return HttpResponseForbidden()
owner_draw = active_draw_for(owner)
sig_card, sig_reversed = _resolve_sig(owner, owner_draw)
owner_hand_non_empty = owner_draw is not None and bool(owner_draw.hand)
return render(request, "apps/gameboard/my_sea_visit.html", {
"spectator": True,
"is_owner": False,
"read_only": True,
"owner": owner,
"sea_invite": invite,
"seat1_present": owner_hand_non_empty,
"seat2_present": invite.is_present,
"owner_draw_id": owner_draw.id if owner_draw is not None else "",
"voice_active": invite.voice_active,
"significator": sig_card,
"significator_reversed": sig_reversed,
"my_sea_slots": latest_draw_slots(owner),
"owner_hand_non_empty": owner_hand_non_empty,
# Owner-only controls forced off on the spectator surface.
"sea_btn_active": False,
"sea_first_draw_pending": False,
"page_class": "page-gameboard page-my-sea page-my-sea-visit",
})
@login_required(login_url="/")
def my_sea_visit_gate(request, owner_id):
"""Visitor gate — single-step token deposit to occupy seat 2C + open the
voice window. Reuses my_sea_gate.html with spectator=True (INSERT TOKEN
only; no refund / PAID-DRAW two-step)."""
from apps.lyric.models import User
owner = get_object_or_404(User, id=owner_id)
invite = _accepted_visit_invite(owner, request.user)
if invite is None:
return HttpResponseForbidden()
sig_card, sig_reversed = _resolve_sig(owner, active_draw_for(owner))
return render(request, "apps/gameboard/my_sea_gate.html", {
"spectator": True,
"owner": owner,
"sea_invite": invite,
"visit_owner_id": owner.id,
"user_has_sig": sig_card is not None,
"significator": sig_card,
"significator_reversed": sig_reversed,
# Visitor gate is single-step — no reserve/commit, so the refund +
# PAID DRAW blocks (gated on `deposit_reserved`) never render.
"deposit_reserved": False,
"hand_non_empty": False,
"page_class": "page-gameboard page-my-sea page-my-sea-gate page-my-sea-visit-gate",
})
@login_required(login_url="/")
@require_POST
def my_sea_visit_insert_token(request, owner_id):
"""Single-step visitor token deposit. Selects + debits the visitor's next-
priority token (same priority chain as the owner gate), then records
`token_deposited_at` + a 24h `voice_until` on the SeaInvite (NOT on the
owner's MySeaDraw), marking seat 2C present + opening the voice window."""
from datetime import timedelta
from apps.lyric.models import User
owner = get_object_or_404(User, id=owner_id)
invite = _accepted_visit_invite(owner, request.user)
if invite is None:
return HttpResponseForbidden()
if invite.token_deposited_at is None:
token = _select_my_sea_token(request.user)
if token is not None:
debit_my_sea_token(request.user, token)
now = timezone.now()
invite.token_deposited_at = now
invite.voice_until = now + timedelta(hours=24)
invite.save(update_fields=["token_deposited_at", "voice_until"])
return redirect("my_sea_visit", owner_id=owner.id)
@login_required(login_url="/")
@require_POST
def my_sea_visit_leave(request, owner_id):
"""Spectator BYE — drop presence: status=LEFT, left_at=now, voice killed
(frees seat 2C + ends the voice window). Matches the requester's latest
non-DECLINED invite for this owner."""
from apps.lyric.models import User
from .models import SeaInvite
owner = get_object_or_404(User, id=owner_id)
invite = (SeaInvite.objects
.filter(owner=owner, invitee=request.user)
.exclude(status=SeaInvite.DECLINED)
.order_by("-created_at").first())
if invite is None:
return HttpResponseForbidden()
invite.status = SeaInvite.LEFT
invite.left_at = timezone.now()
invite.voice_until = None
invite.save(update_fields=["status", "left_at", "voice_until"])
return redirect("gameboard")
def _my_sea_deck_data(user, exclude_id=None):
"""Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Card payload shape is whatever