my-sea voice: cap visitors at 5 (6 seats) + free a seat on leave — TDD

Phase 5a of the my-sea voice batch (user-spec 2026-05-29). The owner holds 1C;
at most 5 visitors fill 2C–6C, which also caps the voice mesh (voice requires a
deposited seat, so seat-capping caps membership).

- SeaInvite: MY_SEA_MAX_VISITORS=5 + present_count(owner) / table_has_room(owner)
  classmethods (present = ACCEPTED + deposited + not LEFT).
- my_sea_visit_insert_token: a fresh deposit into a full table is bounced
  (?full=1, no token spent, no seat); a visitor who BYEs frees their seat
  (is_present → False) for the next visitor.
- my_sea_visit_gate:  context → the gate shows 'TABLE FULL' + inert
  rails instead of INSERT TOKEN for a not-yet-present visitor.
- 6 capacity ITs (count/room, full-table bounce, leave-frees-seat, gate flag,
  already-seated not blocked). 291 gameboard ITs green.

Remaining Phase 5 (live-verify / needs a spec call): disconnect visuals
(--priRd/.fa-ban, item 7) + the true Web-Audio equalizer (item 5) + consumer-
level voice-member enforcement + multi-seat (3C–6C) spectator viz.

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:
Disco DeDisco
2026-05-29 21:35:22 -04:00
parent da97c623c9
commit f0b9f02c7c
4 changed files with 117 additions and 3 deletions

View File

@@ -940,11 +940,15 @@ def my_sea_visit_gate(request, owner_id):
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
from .models import SeaInvite
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))
# Seat cap — a not-yet-present visitor can't deposit into a full table
# (owner + 5). The gate renders a "table full" notice instead of the rails.
table_full = not invite.is_present and not SeaInvite.table_has_room(owner)
return render(request, "apps/gameboard/my_sea_gate.html", {
"spectator": True,
"owner": owner,
@@ -957,6 +961,7 @@ def my_sea_visit_gate(request, owner_id):
# PAID DRAW blocks (gated on `deposit_reserved`) never render.
"deposit_reserved": False,
"hand_non_empty": False,
"table_full": table_full,
"page_class": "page-gameboard page-my-sea page-my-sea-gate page-my-sea-visit-gate",
})
@@ -969,12 +974,21 @@ def my_sea_visit_insert_token(request, owner_id):
`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 django.urls import reverse
from apps.lyric.models import User
from .models import SeaInvite
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:
# Seat cap (user-spec 2026-05-29): owner (1C) + up to 5 visitors. A
# fresh deposit can only take a seat while one is free; a full table
# bounces back w. ?full=1 so the gate can say so. A visitor who LEFT
# frees their seat for someone else (is_present → False).
if not SeaInvite.table_has_room(owner):
return redirect(
reverse("my_sea_visit", args=[owner.id]) + "?full=1")
token = _select_my_sea_token(request.user)
if token is not None:
debit_my_sea_token(request.user, token)