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