From f0b9f02c7c53ab77fb8a4f4d4493e330fdce43d2 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Fri, 29 May 2026 21:35:22 -0400 Subject: [PATCH] =?UTF-8?q?my-sea=20voice:=20cap=20visitors=20at=205=20(6?= =?UTF-8?q?=20seats)=20+=20free=20a=20seat=20on=20leave=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Git commit message Co-Authored-By: Claude Opus 4.8 (1M context) --- src/apps/gameboard/models.py | 19 +++++ .../tests/integrated/test_sea_visit.py | 79 +++++++++++++++++++ src/apps/gameboard/views.py | 14 ++++ src/templates/apps/gameboard/my_sea_gate.html | 8 +- 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index f9ad0c9..1e2c828 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -305,6 +305,9 @@ def active_draw_for(user): # ── My-Sea bud-invite relationship ────────────────────────────────────── SEA_INVITE_EXPIRE_HOURS = 24 +# The owner holds seat 1C; up to 5 visitors fill 2C–6C (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): @@ -442,3 +445,19 @@ class SeaInvite(models.Model): window = timezone.timedelta(hours=SEA_INVITE_EXPIRE_HOURS) return now < self.token_deposited_at + window 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 2C–6C 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 diff --git a/src/apps/gameboard/tests/integrated/test_sea_visit.py b/src/apps/gameboard/tests/integrated/test_sea_visit.py index 8d82dc2..c014311 100644 --- a/src/apps/gameboard/tests/integrated/test_sea_visit.py +++ b/src/apps/gameboard/tests/integrated/test_sea_visit.py @@ -268,6 +268,85 @@ class MySeaVisitLeaveTest(TestCase): 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 + 2C–6C). 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): """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 diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 11e80ee..e9457e6 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -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) diff --git a/src/templates/apps/gameboard/my_sea_gate.html b/src/templates/apps/gameboard/my_sea_gate.html index 47bcfc4..6c136b4 100644 --- a/src/templates/apps/gameboard/my_sea_gate.html +++ b/src/templates/apps/gameboard/my_sea_gate.html @@ -41,8 +41,8 @@ {# `_room.scss`. Layout mirrors the room gatekeeper. #}
-
- {% if not deposit_reserved %} +
+ {% if not deposit_reserved and not table_full %}
{% csrf_token %}
{% else %} + {# Reserved (owner two-step) OR the spectator table is #} + {# full (owner + 5) — static rails, no submit. #}
@@ -58,7 +60,7 @@ {% endif %}
1
- INSERT TOKEN TO PLAY + {% if table_full %}TABLE FULL{% else %}INSERT TOKEN TO PLAY{% endif %} PUSH TO RETURN
{% if deposit_reserved %}