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

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