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

@@ -305,6 +305,9 @@ def active_draw_for(user):
# ── My-Sea bud-invite relationship ────────────────────────────────────── # ── My-Sea bud-invite relationship ──────────────────────────────────────
SEA_INVITE_EXPIRE_HOURS = 24 SEA_INVITE_EXPIRE_HOURS = 24
# The owner holds seat 1C; up to 5 visitors fill 2C6C (6 seats total). Caps
# both the seat count AND the voice mesh (voice requires a deposited seat).
MY_SEA_MAX_VISITORS = 5
class SeaInvite(models.Model): class SeaInvite(models.Model):
@@ -442,3 +445,19 @@ class SeaInvite(models.Model):
window = timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS) window = timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS)
return now < self.token_deposited_at + window return now < self.token_deposited_at + window
return False return False
@classmethod
def present_count(cls, owner):
"""How many invitees currently occupy a seat at `owner`'s my-sea —
ACCEPTED + token deposited + not yet LEFT (i.e. `is_present`). The
owner holds 1C; this counts the 2C6C occupants. Caps seating + voice
at MY_SEA_MAX_VISITORS (user-spec 2026-05-29)."""
return cls.objects.filter(
owner=owner, status=cls.ACCEPTED,
token_deposited_at__isnull=False, left_at__isnull=True,
).count()
@classmethod
def table_has_room(cls, owner):
"""True iff a fresh visitor can still take a seat (< 5 present)."""
return cls.present_count(owner) < MY_SEA_MAX_VISITORS

View File

@@ -268,6 +268,85 @@ class MySeaVisitLeaveTest(TestCase):
self.assertEqual(self.client.post(self.url).status_code, 403) self.assertEqual(self.client.post(self.url).status_code, 403)
class MySeaVisitCapacityTest(TestCase):
"""Phase 5 (2026-05-29) — at most 5 visitors seat at a bud's my-sea
(owner 1C + 2C6C). A full table bounces a fresh deposit; a visitor who
leaves frees their seat for someone else."""
def setUp(self):
from apps.gameboard.models import MY_SEA_MAX_VISITORS
self.MAX = MY_SEA_MAX_VISITORS
self.owner = _owner_with_sig()
def _accepted_invitee(self, n, present=False):
u = User.objects.create(email=f"v{n}@test.io", username=f"v{n}")
inv = SeaInvite.objects.create(
owner=self.owner, invitee=u, invitee_email=u.email,
status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
)
if present:
inv.token_deposited_at = timezone.now()
inv.voice_until = timezone.now() + timedelta(hours=24)
inv.save()
return u, inv
def _fill_table(self):
# MAX present visitors → the table is full.
for n in range(self.MAX):
self._accepted_invitee(n, present=True)
def test_present_count_and_table_has_room(self):
self._fill_table()
self.assertEqual(SeaInvite.present_count(self.owner), self.MAX)
self.assertFalse(SeaInvite.table_has_room(self.owner))
def test_full_table_bounces_a_fresh_deposit(self):
self._fill_table()
latecomer, inv = self._accepted_invitee(99, present=False)
self.client.force_login(latecomer)
resp = self.client.post(
reverse("my_sea_visit_insert_token", args=[self.owner.id]))
inv.refresh_from_db()
self.assertIsNone(inv.token_deposited_at) # no seat taken
self.assertFalse(inv.is_present)
self.assertIn("full=1", resp["Location"])
def test_leaving_frees_a_seat_for_the_next_visitor(self):
self._fill_table()
# One present visitor leaves → a seat opens.
leaver = SeaInvite.objects.filter(
owner=self.owner, token_deposited_at__isnull=False).first()
self.client.force_login(leaver.invitee)
self.client.post(reverse("my_sea_visit_leave", args=[self.owner.id]))
self.assertTrue(SeaInvite.table_has_room(self.owner))
# The latecomer can now deposit into the freed seat.
latecomer, inv = self._accepted_invitee(99, present=False)
self.client.force_login(latecomer)
self.client.post(
reverse("my_sea_visit_insert_token", args=[self.owner.id]))
inv.refresh_from_db()
self.assertTrue(inv.is_present)
def test_gate_flags_table_full_for_a_non_present_visitor(self):
self._fill_table()
latecomer, _ = self._accepted_invitee(99, present=False)
self.client.force_login(latecomer)
ctx = self.client.get(
reverse("my_sea_visit_gate", args=[self.owner.id])).context
self.assertTrue(ctx["table_full"])
def test_already_present_visitor_is_not_blocked_by_a_full_table(self):
# The 5th present visitor re-POSTing insert is a no-op (already seated),
# never a "full" bounce.
self._fill_table()
seated = SeaInvite.objects.filter(
owner=self.owner, token_deposited_at__isnull=False).first()
self.client.force_login(seated.invitee)
ctx = self.client.get(
reverse("my_sea_visit_gate", args=[self.owner.id])).context
self.assertFalse(ctx["table_full"])
class MySeaVoiceContextTest(TestCase): class MySeaVoiceContextTest(TestCase):
"""Phase C — the #id_voice_btn lights up (voice_active) for both the """Phase C — the #id_voice_btn lights up (voice_active) for both the
owner and the present invitee while the 24h voice window is open, keyed on owner and the present invitee while the 24h voice window is open, keyed on

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 voice window. Reuses my_sea_gate.html with spectator=True (INSERT TOKEN
only; no refund / PAID-DRAW two-step).""" only; no refund / PAID-DRAW two-step)."""
from apps.lyric.models import User from apps.lyric.models import User
from .models import SeaInvite
owner = get_object_or_404(User, id=owner_id) owner = get_object_or_404(User, id=owner_id)
invite = _accepted_visit_invite(owner, request.user) invite = _accepted_visit_invite(owner, request.user)
if invite is None: if invite is None:
return HttpResponseForbidden() return HttpResponseForbidden()
sig_card, sig_reversed = _resolve_sig(owner, active_draw_for(owner)) 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", { return render(request, "apps/gameboard/my_sea_gate.html", {
"spectator": True, "spectator": True,
"owner": owner, "owner": owner,
@@ -957,6 +961,7 @@ def my_sea_visit_gate(request, owner_id):
# PAID DRAW blocks (gated on `deposit_reserved`) never render. # PAID DRAW blocks (gated on `deposit_reserved`) never render.
"deposit_reserved": False, "deposit_reserved": False,
"hand_non_empty": 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", "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 `token_deposited_at` + a 24h `voice_until` on the SeaInvite (NOT on the
owner's MySeaDraw), marking seat 2C present + opening the voice window.""" owner's MySeaDraw), marking seat 2C present + opening the voice window."""
from datetime import timedelta from datetime import timedelta
from django.urls import reverse
from apps.lyric.models import User from apps.lyric.models import User
from .models import SeaInvite
owner = get_object_or_404(User, id=owner_id) owner = get_object_or_404(User, id=owner_id)
invite = _accepted_visit_invite(owner, request.user) invite = _accepted_visit_invite(owner, request.user)
if invite is None: if invite is None:
return HttpResponseForbidden() return HttpResponseForbidden()
if invite.token_deposited_at is None: 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) token = _select_my_sea_token(request.user)
if token is not None: if token is not None:
debit_my_sea_token(request.user, token) debit_my_sea_token(request.user, token)

View File

@@ -41,8 +41,8 @@
{# `_room.scss`. Layout mirrors the room gatekeeper. #} {# `_room.scss`. Layout mirrors the room gatekeeper. #}
<div class="gate-top-row"> <div class="gate-top-row">
<div class="gate-main-panel"> <div class="gate-main-panel">
<div class="token-slot{% if not deposit_reserved %} active{% else %} pending{% endif %}"> <div class="token-slot{% if not deposit_reserved and not table_full %} active{% else %} pending{% endif %}">
{% if not deposit_reserved %} {% if not deposit_reserved and not table_full %}
<form method="POST" action="{% if spectator %}{% url 'my_sea_visit_insert_token' visit_owner_id %}{% else %}{% url 'my_sea_insert_token' %}{% endif %}" style="display:contents"> <form method="POST" action="{% if spectator %}{% url 'my_sea_visit_insert_token' visit_owner_id %}{% else %}{% url 'my_sea_insert_token' %}{% endif %}" style="display:contents">
{% csrf_token %} {% csrf_token %}
<button type="submit" class="token-rails" aria-label="Insert token to play"> <button type="submit" class="token-rails" aria-label="Insert token to play">
@@ -51,6 +51,8 @@
</button> </button>
</form> </form>
{% else %} {% else %}
{# Reserved (owner two-step) OR the spectator table is #}
{# full (owner + 5) — static rails, no submit. #}
<div class="token-rails"> <div class="token-rails">
<span class="rail"></span> <span class="rail"></span>
<span class="rail"></span> <span class="rail"></span>
@@ -58,7 +60,7 @@
{% endif %} {% endif %}
<div class="token-panel"> <div class="token-panel">
<div class="token-denomination">1</div> <div class="token-denomination">1</div>
<span class="token-insert-label">INSERT TOKEN TO PLAY</span> <span class="token-insert-label">{% if table_full %}TABLE FULL{% else %}INSERT TOKEN TO PLAY{% endif %}</span>
<span class="token-return-label">PUSH TO RETURN</span> <span class="token-return-label">PUSH TO RETURN</span>
</div> </div>
{% if deposit_reserved %} {% if deposit_reserved %}