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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user