room auto-BYE: free seats lapsed past the renewal grace (2× renewal_period) → RENEWAL_DUE gamer-needed stub — TDD

Phase 5 of the room GATE VIEW + seat-renewal sprint. A seated gamer who
never renews is evicted once their seat's cost passes the renewal-grace
window (filled_at + 2*renewal_period; 14d at the 7d default).

- _expire_lapsed_seats(room): mirrors _expire_reserved_slots — for each
  FILLED slot past 2S, blanks the GateSlot, blanks the matching TableSeat
  (keeps the row for seat-count integrity), records SLOT_RETURNED +
  retracts the prior SLOT_FILLED (scroll redact-pair symmetry), then
  flags the room RENEWAL_DUE. NULL filled_at is never expired (RESERVED
  holds / ORM fixtures / auto-admit trinkets) — protects every existing
  FILLED-slot test
- lazy call sites: room_view, gatekeeper, room_gate (on access; mirrors
  the my-sea delete_stale pattern — no scheduler needed for active rooms)
- room.html: RENEWAL_DUE renders a minimal #id_gamer_needed stub
  (_table_positions + _gatekeeper already suppressed for RENEWAL_DUE).
  Mid-game re-seat flow is a documented follow-on

Tests: ExpireLapsedSeatsTest (10) — frees slot + blanks seat past grace;
no-op within cost window / grace / for null filled_at; sets RENEWAL_DUE;
records SLOT_RETURNED; lazy expiry on room_view + room_gate access;
gamer-needed stub renders. 848 epic+gameboard ITs green.

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-31 23:34:20 -04:00
parent 4b3dc91e7f
commit 0cd16861cd
3 changed files with 164 additions and 0 deletions

View File

@@ -2893,3 +2893,105 @@ class RoomRoleStackGraceTest(TestCase):
self.assertContains(response, 'data-state="eligible"') self.assertContains(response, 'data-state="eligible"')
# …alongside the GATE VIEW supersession of the non-ROLE affordances. # …alongside the GATE VIEW supersession of the non-ROLE affordances.
self.assertContains(response, "id_room_gate_view_btn") self.assertContains(response, "id_room_gate_view_btn")
class ExpireLapsedSeatsTest(TestCase):
"""Auto-BYE: a seat whose token cost lapsed past the renewal grace
(filled_at + 2*renewal_period) is freed — GateSlot blanked, TableSeat
blanked (row kept for seat-count integrity), SLOT_RETURNED recorded,
room flagged RENEWAL_DUE (gamer needed). Lazy on room/gate-view access.
NULL filled_at never expires (protects fixtures / RESERVED holds)."""
def setUp(self):
self.founder = User.objects.create(email="founder@test.io", username="founder")
self.room = Room.objects.create(
name="Lapse Room", owner=self.founder,
renewal_period=timedelta(days=7),
gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.founder
self.slot.status = GateSlot.FILLED
self.slot.debited_token_type = Token.FREE
self.slot.save()
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.founder, slot_number=1, role="PC",
)
self.client.force_login(self.founder)
def _set_filled(self, when):
self.slot.filled_at = when
self.slot.save()
def test_frees_gateslot_past_grace(self):
from apps.epic.views import _expire_lapsed_seats
self._set_filled(timezone.now() - timedelta(days=15)) # > 2S = 14d
_expire_lapsed_seats(self.room)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
self.assertIsNone(self.slot.gamer)
self.assertIsNone(self.slot.filled_at)
self.assertIsNone(self.slot.debited_token_type)
def test_blanks_table_seat(self):
from apps.epic.views import _expire_lapsed_seats
self._set_filled(timezone.now() - timedelta(days=15))
_expire_lapsed_seats(self.room)
self.seat.refresh_from_db()
self.assertIsNone(self.seat.gamer)
self.assertIsNone(self.seat.role)
def test_noop_within_grace(self):
from apps.epic.views import _expire_lapsed_seats
self._set_filled(timezone.now() - timedelta(days=8)) # in grace [7,14)
_expire_lapsed_seats(self.room)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
def test_noop_within_cost_window(self):
from apps.epic.views import _expire_lapsed_seats
self._set_filled(timezone.now())
_expire_lapsed_seats(self.room)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
def test_ignores_null_filled_at(self):
from apps.epic.views import _expire_lapsed_seats
self.slot.filled_at = None
self.slot.save()
_expire_lapsed_seats(self.room)
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.FILLED)
def test_sets_renewal_due(self):
from apps.epic.views import _expire_lapsed_seats
self._set_filled(timezone.now() - timedelta(days=15))
_expire_lapsed_seats(self.room)
self.room.refresh_from_db()
self.assertEqual(self.room.gate_status, Room.RENEWAL_DUE)
def test_records_slot_returned(self):
from apps.epic.views import _expire_lapsed_seats
self._set_filled(timezone.now() - timedelta(days=15))
_expire_lapsed_seats(self.room)
self.assertTrue(
self.room.events.filter(
actor=self.founder, verb=GameEvent.SLOT_RETURNED).exists())
def test_room_view_runs_expiry_on_access(self):
self._set_filled(timezone.now() - timedelta(days=15))
self.client.get(reverse("epic:room", args=[self.room.id]))
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
def test_room_gate_runs_expiry_on_access(self):
self._set_filled(timezone.now() - timedelta(days=15))
self.client.get(reverse("epic:room_gate", args=[self.room.id]))
self.slot.refresh_from_db()
self.assertEqual(self.slot.status, GateSlot.EMPTY)
def test_gamer_needed_stub_renders(self):
self.room.gate_status = Room.RENEWAL_DUE
self.room.save()
response = self.client.get(reverse("epic:room", args=[self.room.id]))
self.assertContains(response, "id_gamer_needed")

View File

@@ -201,6 +201,55 @@ def _expire_reserved_slots(room):
).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None) ).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)
def _expire_lapsed_seats(room):
"""Auto-BYE gamers whose seat token cost lapsed past the renewal grace
(filled_at + 2*renewal_period). For each lapsed FILLED slot: blank the
GateSlot, blank the matching TableSeat (keep the row — `_gate_positions`
+ sig logic count seat rows), record a SLOT_RETURNED (+ retract the
prior SLOT_FILLED so the scroll's deposit↔withdraw redact-pair stays
symmetric), then flag the room RENEWAL_DUE ("gamer needed" stub). Lazy —
called on room + gate-view access, mirroring `_expire_reserved_slots`.
NULL filled_at slots are never expired (RESERVED holds / ORM fixtures /
auto-admit trinkets whose seat clock simply hasn't started)."""
span = room.renewal_period or timedelta(days=7)
cutoff = timezone.now() - 2 * span
lapsed = list(
room.gate_slots.filter(
status=GateSlot.FILLED,
filled_at__isnull=False,
filled_at__lt=cutoff,
)
)
if not lapsed:
return
for slot in lapsed:
gamer = slot.gamer
token_type = slot.debited_token_type
slot_number = slot.slot_number
if gamer is not None:
# Blank the seat row (vacate the position circle) — keep the row.
room.table_seats.filter(gamer=gamer).update(
gamer=None, role=None, role_revealed=False,
seat_position=None, significator=None, deck_variant=None,
)
slot.gamer = None
slot.status = GateSlot.EMPTY
slot.filled_at = None
slot.debited_token_type = None
slot.debited_token_expires_at = None
slot.save()
if gamer is not None and token_type:
_retract_prior_event(
room, gamer, (GameEvent.SLOT_FILLED,), slot_number=slot_number,
)
record(room, GameEvent.SLOT_RETURNED, actor=gamer,
slot_number=slot_number, token_type=token_type,
token_display=dict(Token.TOKEN_TYPE_CHOICES).get(
token_type, token_type))
room.gate_status = Room.RENEWAL_DUE
room.save(update_fields=["gate_status"])
def _gate_context(room, user): def _gate_context(room, user):
_expire_reserved_slots(room) _expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number") slots = room.gate_slots.order_by("slot_number")
@@ -450,6 +499,7 @@ def create_room(request):
def gatekeeper(request, room_id): def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
if room.table_status: if room.table_status:
return redirect("epic:room", room_id=room_id) return redirect("epic:room", room_id=room_id)
ctx = _gate_context(room, request.user) ctx = _gate_context(room, request.user)
@@ -460,6 +510,7 @@ def gatekeeper(request, room_id):
def room_view(request, room_id): def room_view(request, room_id):
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
ctx = _role_select_context(room, request.user) ctx = _role_select_context(room, request.user)
ctx["room"] = room ctx["room"] = room
# `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's # `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's
@@ -486,6 +537,7 @@ def room_gate(request, room_id):
time-remaining live on the table hex / next-sprint user-seat tooltips, time-remaining live on the table hex / next-sprint user-seat tooltips,
so they're intentionally absent here (user-spec 2026-05-31).""" so they're intentionally absent here (user-spec 2026-05-31)."""
room = Room.objects.get(id=room_id) room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
user_slot = room.gate_slots.filter( user_slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED gamer=request.user, status=GateSlot.FILLED
).first() ).first()

View File

@@ -109,6 +109,16 @@
{% include "apps/gameboard/_partials/_sea_overlay.html" %} {% include "apps/gameboard/_partials/_sea_overlay.html" %}
{% endif %} {% endif %}
{# Gamer-needed stub — a seat lapsed past its renewal grace and was #}
{# auto-BYE'd, so the table no longer fills all six. Minimal stub #}
{# until the mid-game re-seat flow lands (_table_positions + #}
{# _gatekeeper are already suppressed for RENEWAL_DUE below). #}
{% if room.gate_status == "RENEWAL_DUE" %}
<div id="id_gamer_needed" class="hex-stub">
<p>A seat opened — awaiting a gamer.</p>
</div>
{% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %} {% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %} {% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %} {% endif %}