diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index aa14028..ae2e718 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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") diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index b4b297c..c312601 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -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() diff --git a/src/templates/apps/gameboard/room.html b/src/templates/apps/gameboard/room.html index 8328d81..e1a71af 100644 --- a/src/templates/apps/gameboard/room.html +++ b/src/templates/apps/gameboard/room.html @@ -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" %} +
+

A seat opened — awaiting a gamer.

+
+ {% endif %} + {% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %} {% include "apps/gameboard/_partials/_table_positions.html" %} {% endif %}