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:
53
src/apps/gameboard/static/apps/gameboard/my-sea-seats.js
Normal file
53
src/apps/gameboard/static/apps/gameboard/my-sea-seats.js
Normal 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);
|
||||
});
|
||||
});
|
||||
}());
|
||||
175
src/apps/gameboard/tests/integrated/test_sea_visit.py
Normal file
175
src/apps/gameboard/tests/integrated/test_sea_visit.py
Normal 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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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