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

@@ -201,6 +201,55 @@ def _expire_reserved_slots(room):
).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):
_expire_reserved_slots(room)
slots = room.gate_slots.order_by("slot_number")
@@ -450,6 +499,7 @@ def create_room(request):
def gatekeeper(request, room_id):
room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
if room.table_status:
return redirect("epic:room", room_id=room_id)
ctx = _gate_context(room, request.user)
@@ -460,6 +510,7 @@ def gatekeeper(request, room_id):
def room_view(request, room_id):
room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
ctx = _role_select_context(room, request.user)
ctx["room"] = room
# `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,
so they're intentionally absent here (user-spec 2026-05-31)."""
room = Room.objects.get(id=room_id)
_expire_lapsed_seats(room)
user_slot = room.gate_slots.filter(
gamer=request.user, status=GateSlot.FILLED
).first()