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