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:
@@ -2893,3 +2893,105 @@ class RoomRoleStackGraceTest(TestCase):
|
||||
self.assertContains(response, 'data-state="eligible"')
|
||||
# …alongside the GATE VIEW supersession of the non-ROLE affordances.
|
||||
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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -109,6 +109,16 @@
|
||||
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
|
||||
{% 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" %}
|
||||
{% include "apps/gameboard/_partials/_table_positions.html" %}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user