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"')
|
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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user