diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 0ae9cea..479d8aa 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -71,10 +71,23 @@ class GameEvent(models.Model): return f"deposits a {token} for slot {slot} (expires in {days} days)." if self.verb == self.SLOT_RESERVED: return "reserves a seat" - if self.verb == self.SLOT_RETURNED: - return "withdraws from the gate" - if self.verb == self.SLOT_RELEASED: - return f"releases slot {d.get('slot_number', '?')}" + if self.verb in (self.SLOT_RETURNED, self.SLOT_RELEASED): + # Symmetric counterpart to SLOT_FILLED's "deposits a {token} for + # slot {#} …" — same shape so the redact-pair (strikethrough on + # the prior deposit, new withdraw entry below it) reads as a + # mirror image in the room scroll. User-spec 2026-05-26 sprint + # A.8. SLOT_RETURNED + SLOT_RELEASED both render w. this prose; + # the verb distinction stays in the data layer (different paths + # trigger them — full token return vs. per-slot CARTE release). + _token_names = { + "coin": "Coin-on-a-String", "Free": "Free Token", + "tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche", + } + code = d.get("token_type", "token") + token = d.get("token_display") or _token_names.get(code, code) + slot = d.get("slot_number", "?") + _, _, poss = _actor_pronouns(self.actor) + return f"withdraws {poss} {token} from slot {slot}." if self.verb == self.ROOM_CREATED: # First scroll log on a fresh room — system-authored greeting # (actor=None upstream). Format intentionally drops the actor diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index 05a9c33..9bb627c 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -181,13 +181,33 @@ class GameEventModelTest(TestCase): event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user) self.assertEqual(event.to_prose(), "reserves a seat") - def test_slot_returned_prose(self): - event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user) - self.assertEqual(event.to_prose(), "withdraws from the gate") + def test_slot_returned_prose_includes_token_and_slot(self): + # Sprint A.8 (2026-05-26): SLOT_RETURNED now mirrors SLOT_FILLED's + # shape — symmetric redact-pair on the scroll. Data fields match + # the deposit event so the prose reads as a clean mirror. + event = record( + self.room, GameEvent.SLOT_RETURNED, actor=self.user, + slot_number=2, token_type="coin", + token_display="Coin-on-a-String", + ) + prose = event.to_prose() + self.assertIn("withdraws", prose) + self.assertIn("Coin-on-a-String", prose) + self.assertIn("slot 2", prose) - def test_slot_released_prose_includes_slot_number(self): - event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3) - self.assertIn("slot 3", event.to_prose()) + def test_slot_released_prose_uses_unified_withdraw_shape(self): + # SLOT_RELEASED (per-slot CARTE release) shares the unified + # withdraw prose w. SLOT_RETURNED — same visual mirror of the + # deposit. The verb distinction stays in the data layer. + event = record( + self.room, GameEvent.SLOT_RELEASED, actor=self.user, + slot_number=3, token_type="carte", + token_display="Carte Blanche", + ) + prose = event.to_prose() + self.assertIn("withdraws", prose) + self.assertIn("Carte Blanche", prose) + self.assertIn("slot 3", prose) def test_invite_sent_prose(self): event = record(self.room, GameEvent.INVITE_SENT, actor=self.user) diff --git a/src/apps/epic/tests/integrated/test_token_redact_pair.py b/src/apps/epic/tests/integrated/test_token_redact_pair.py new file mode 100644 index 0000000..779c3fc --- /dev/null +++ b/src/apps/epic/tests/integrated/test_token_redact_pair.py @@ -0,0 +1,247 @@ +"""ITs for the gatekeeper token deposit↔withdraw redact-pair pattern — +sprint A.8 user-spec 2026-05-26. + +Mirrors the sig embody/disembody redact mechanism (already wired in +`sig_ready`): every state transition emits a new GameEvent AND marks +the most-recent unretracted counterpart as `data.retracted=True` so the +room scroll renders the prior entry strikethrough + Redact-tagged. + +Pairs covered: +- confirm_token (SLOT_FILLED) ↔ return_token / release_slot (SLOT_RETURNED / SLOT_RELEASED) + +All paths exercised: +- Non-CARTE deposit + return single slot +- CARTE per-slot deposit + per-slot release +- CARTE full-token return (multi-slot withdraw — one entry per slot) +- Re-deposit after withdraw — retracts the prior withdraw +""" +from datetime import timedelta + +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.drama.models import GameEvent +from apps.epic.models import GateSlot, Room +from apps.lyric.models import Token, User + + +def _events(room, actor=None): + qs = room.events.all() + if actor is not None: + qs = qs.filter(actor=actor) + return qs + + +class TokenWithdrawRedactPairTest(TestCase): + """Single-slot deposit/withdraw cycle on a non-CARTE token.""" + + def setUp(self): + self.gamer = User.objects.create(email="gamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.slot = self.room.gate_slots.get(slot_number=1) + # Seed a FILLED slot w. a Coin debited (skipping the confirm_token + # path so we control the SLOT_FILLED event payload directly). + self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN) + self.coin.current_room = self.room + self.coin.save() + self.slot.gamer = self.gamer + self.slot.status = GateSlot.FILLED + self.slot.debited_token_type = Token.COIN + self.slot.debited_token_expires_at = timezone.now() + timedelta(days=7) + self.slot.save() + # Seed the matching SLOT_FILLED event (what confirm_token would emit). + from apps.drama.models import record + record( + self.room, GameEvent.SLOT_FILLED, actor=self.gamer, + slot_number=1, token_type=Token.COIN, + token_display="Coin-on-a-String", renewal_days=7, + ) + + def test_return_emits_slot_returned_event_for_actor(self): + self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id})) + returned = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_RETURNED, + ) + self.assertEqual(returned.count(), 1) + ev = returned.first() + self.assertEqual(ev.data["slot_number"], 1) + self.assertEqual(ev.data["token_type"], Token.COIN) + + def test_return_retracts_prior_slot_filled_for_same_slot(self): + self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id})) + prior = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_FILLED, + ).first() + self.assertIsNotNone(prior) + self.assertTrue(prior.struck, "Prior SLOT_FILLED must be marked retracted") + + def test_returned_event_renders_unified_withdraw_prose(self): + self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id})) + ev = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_RETURNED, + ).first() + prose = ev.to_prose() + self.assertIn("withdraws", prose) + self.assertIn("Coin-on-a-String", prose) + self.assertIn("slot 1", prose) + + def test_reserved_only_return_does_not_emit_withdraw_event(self): + # If the slot is only RESERVED (no SLOT_FILLED ever recorded by + # the matching deposit), the return path clears state but emits + # no withdraw entry — there's no deposit to mirror. + self.slot.status = GateSlot.RESERVED + self.slot.debited_token_type = None + self.slot.debited_token_expires_at = None + self.slot.save() + self.room.events.filter(verb=GameEvent.SLOT_FILLED).delete() + self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id})) + self.assertEqual( + _events(self.room, self.gamer).filter(verb=GameEvent.SLOT_RETURNED).count(), + 0, + ) + + +class TokenRedepositAfterWithdrawTest(TestCase): + """A re-deposit after a withdraw redacts the prior withdraw entry — + closes the loop on the symmetric pair (deposit ↔ withdraw ↔ deposit).""" + + def setUp(self): + self.gamer = User.objects.create(email="reuser@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + # Seed a prior SLOT_RETURNED event (as though the user had just + # withdrawn from slot 1). The next confirm_token should retract it. + from apps.drama.models import record + record( + self.room, GameEvent.SLOT_RETURNED, actor=self.gamer, + slot_number=1, token_type=Token.COIN, + token_display="Coin-on-a-String", + ) + # Now set up the slot for confirm_token to land — RESERVED w. the + # coin available in the user's kit. + self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN) + self.slot = self.room.gate_slots.get(slot_number=1) + self.slot.gamer = self.gamer + self.slot.status = GateSlot.RESERVED + self.slot.reserved_at = timezone.now() + self.slot.save() + + def test_redeposit_retracts_prior_withdraw_event(self): + self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) + prior = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_RETURNED, + ).first() + self.assertIsNotNone(prior) + self.assertTrue(prior.struck, "Prior SLOT_RETURNED must be retracted on redeposit") + + def test_redeposit_emits_new_slot_filled_entry(self): + self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) + filled = _events(self.room, self.gamer).filter(verb=GameEvent.SLOT_FILLED) + self.assertEqual(filled.count(), 1) + ev = filled.first() + self.assertEqual(ev.data["slot_number"], 1) + self.assertFalse(ev.struck, "New deposit event starts unretracted") + + +class CarteFullReturnPerSlotRedactPairTest(TestCase): + """CARTE full token return emits one SLOT_RETURNED entry per slot it + was claiming + retracts the matching SLOT_FILLED for each slot.""" + + def setUp(self): + self.gamer = User.objects.create(email="cartegamer@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.carte = Token.objects.create( + user=self.gamer, token_type=Token.CARTE, current_room=self.room, + slots_claimed=3, + ) + # CARTE has claimed slots 1, 2, 3. + from apps.drama.models import record + for n in (1, 2, 3): + slot = self.room.gate_slots.get(slot_number=n) + slot.gamer = self.gamer + slot.status = GateSlot.FILLED + slot.debited_token_type = Token.CARTE + slot.debited_token_expires_at = timezone.now() + timedelta(days=7) + slot.save() + record( + self.room, GameEvent.SLOT_FILLED, actor=self.gamer, + slot_number=n, token_type=Token.CARTE, + token_display="Carte Blanche", renewal_days=7, + ) + + def test_carte_full_return_emits_one_withdraw_per_claimed_slot(self): + self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id})) + returned = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_RETURNED, + ) + self.assertEqual(returned.count(), 3) + slot_numbers = sorted(ev.data["slot_number"] for ev in returned) + self.assertEqual(slot_numbers, [1, 2, 3]) + + def test_carte_full_return_retracts_each_slots_prior_deposit(self): + self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id})) + for n in (1, 2, 3): + deposit = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_FILLED, data__slot_number=n, + ).first() + self.assertIsNotNone(deposit, f"slot {n} deposit must exist") + self.assertTrue( + deposit.struck, + f"slot {n} deposit must be retracted after CARTE full return", + ) + + +class ReleaseSlotRedactPairTest(TestCase): + """release_slot (per-slot CARTE release) emits SLOT_RELEASED + retracts + that slot's prior SLOT_FILLED. The CARTE token itself stays in play.""" + + def setUp(self): + self.gamer = User.objects.create(email="rel@test.io") + self.client.force_login(self.gamer) + owner = User.objects.create(email="owner@test.io") + self.room = Room.objects.create(name="Test Room", owner=owner) + self.carte = Token.objects.create( + user=self.gamer, token_type=Token.CARTE, current_room=self.room, + slots_claimed=2, + ) + # CARTE-claimed slot 2 — the one we'll release. + slot = self.room.gate_slots.get(slot_number=2) + slot.gamer = self.gamer + slot.status = GateSlot.FILLED + slot.debited_token_type = Token.CARTE + slot.debited_token_expires_at = timezone.now() + timedelta(days=7) + slot.save() + from apps.drama.models import record + record( + self.room, GameEvent.SLOT_FILLED, actor=self.gamer, + slot_number=2, token_type=Token.CARTE, + token_display="Carte Blanche", renewal_days=7, + ) + + def test_release_emits_slot_released_event_for_that_slot(self): + self.client.post( + reverse("epic:release_slot", kwargs={"room_id": self.room.id}), + data={"slot_number": "2"}, + ) + released = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_RELEASED, + ) + self.assertEqual(released.count(), 1) + self.assertEqual(released.first().data["slot_number"], 2) + + def test_release_retracts_prior_deposit_on_that_slot(self): + self.client.post( + reverse("epic:release_slot", kwargs={"room_id": self.room.id}), + data={"slot_number": "2"}, + ) + deposit = _events(self.room, self.gamer).filter( + verb=GameEvent.SLOT_FILLED, data__slot_number=2, + ).first() + self.assertIsNotNone(deposit) + self.assertTrue(deposit.struck) diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index ee93056..eb54daf 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -29,6 +29,27 @@ from apps.lyric.models import Token RESERVE_TIMEOUT = timedelta(seconds=60) +def _retract_prior_event(room, actor, verbs, slot_number=None): + """Mark the most-recent unretracted GameEvent for `actor` on `room` + matching one of `verbs` (and optional `slot_number`) as retracted. + Drives the symmetric redact-pair pattern in the room scroll: every + state transition (deposit ↔ withdraw, sig-ready ↔ sig-unready) sets + `data.retracted=True` on its counterpart's prior entry, which the + scroll template renders strikethrough + Redact-tagged. + + `verbs` is a list/tuple — e.g. when a deposit lands, retract the + prior SLOT_RETURNED *or* SLOT_RELEASED for the same slot (both + represent a withdraw of the slot in question). No-op if no matching + unretracted prior exists. Sprint A.8 user-spec 2026-05-26.""" + qs = room.events.filter(actor=actor, verb__in=verbs) + if slot_number is not None: + qs = qs.filter(data__slot_number=slot_number) + prior = qs.last() + if prior and not prior.data.get("retracted"): + prior.data["retracted"] = True + prior.save(update_fields=["data"]) + + def _notify_gate_update(room_id): async_to_sync(get_channel_layer().group_send)( f'room_{room_id}', @@ -496,6 +517,15 @@ def confirm_token(request, room_id): if int(slot_number) > carte.slots_claimed: carte.slots_claimed = int(slot_number) carte.save() + # Redact-pair: a re-deposit on this slot strikes the most- + # recent unretracted withdraw entry for this slot (user- + # spec 2026-05-26 — symmetric mirror of the sig embody/ + # disembody pattern). + _retract_prior_event( + room, request.user, + (GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED), + slot_number=int(slot_number), + ) record(room, GameEvent.SLOT_FILLED, actor=request.user, slot_number=int(slot_number), token_type=Token.CARTE, token_display=carte.get_token_type_display(), @@ -514,6 +544,13 @@ def confirm_token(request, room_id): token = select_token(request.user) if token: debit_token(request.user, slot, token) + # Redact-pair: re-deposit on this slot strikes the prior + # unretracted withdraw entry for this slot (sprint A.8). + _retract_prior_event( + room, request.user, + (GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED), + slot_number=slot.slot_number, + ) record(room, GameEvent.SLOT_FILLED, actor=request.user, slot_number=slot.slot_number, token_type=token.token_type, token_display=token.get_token_type_display(), @@ -526,11 +563,20 @@ def confirm_token(request, room_id): def return_token(request, room_id): if request.method == "POST": room = Room.objects.get(id=room_id) - # CARTE full return: reset token + all CARTE-debited slots + # CARTE full return: reset token + all CARTE-debited slots. Snapshot + # the slot numbers BEFORE the bulk update so we can emit a per-slot + # withdraw + redact pair (one entry per slot was deposited, so one + # entry per slot is withdrawn — symmetric mirror per user-spec + # 2026-05-26 sprint A.8). carte = request.user.tokens.filter( token_type=Token.CARTE, current_room=room ).first() if carte: + carte_slot_numbers = list( + room.gate_slots.filter( + debited_token_type=Token.CARTE, gamer=request.user, + ).values_list("slot_number", flat=True) + ) room.gate_slots.filter( debited_token_type=Token.CARTE, gamer=request.user ).update( @@ -541,6 +587,13 @@ def return_token(request, room_id): carte.slots_claimed = 0 carte.save() request.session.pop("kit_token_id", None) + for n in carte_slot_numbers: + _retract_prior_event( + room, request.user, (GameEvent.SLOT_FILLED,), slot_number=n, + ) + record(room, GameEvent.SLOT_RETURNED, actor=request.user, + slot_number=n, token_type=Token.CARTE, + token_display=carte.get_token_type_display()) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) slot = room.gate_slots.filter( @@ -548,6 +601,10 @@ def return_token(request, room_id): status__in=[GateSlot.RESERVED, GateSlot.FILLED], ).first() if slot: + # Snapshot token-type + slot-number BEFORE the slot reset so the + # log entry carries the right payload. + withdraw_token_type = slot.debited_token_type + withdraw_slot_number = slot.slot_number if slot.status == GateSlot.FILLED: if slot.debited_token_type == Token.COIN: coin = request.user.tokens.filter( @@ -564,6 +621,7 @@ def return_token(request, room_id): expires_at=slot.debited_token_expires_at, ) request.session.pop("kit_token_id", None) + was_filled = slot.status == GateSlot.FILLED slot.gamer = None slot.status = GateSlot.EMPTY slot.reserved_at = None @@ -571,13 +629,34 @@ def return_token(request, room_id): slot.debited_token_type = None slot.debited_token_expires_at = None slot.save() + # Only emit a withdraw entry when a deposit was actually undone + # (FILLED → EMPTY). RESERVED → EMPTY is a pre-confirm cancel + # that never recorded a SLOT_FILLED, so no redact-pair fires. + if was_filled and withdraw_token_type: + _retract_prior_event( + room, request.user, (GameEvent.SLOT_FILLED,), + slot_number=withdraw_slot_number, + ) + token_display = dict(Token.TOKEN_TYPE_CHOICES).get( + withdraw_token_type, withdraw_token_type, + ) + record(room, GameEvent.SLOT_RETURNED, actor=request.user, + slot_number=withdraw_slot_number, + token_type=withdraw_token_type, + token_display=token_display) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) @login_required def release_slot(request, room_id): - """Un-fill a single CARTE-claimed slot without returning the CARTE itself.""" + """Un-fill a single CARTE-claimed slot without returning the CARTE itself. + + Emits a SLOT_RELEASED event (renders w. the unified withdraw prose, + shape-matched to the deposit) and retracts the corresponding prior + SLOT_FILLED so the room scroll renders the redact-pair per user-spec + 2026-05-26 (sprint A.8). + """ if request.method == "POST": room = Room.objects.get(id=room_id) slot_number = request.POST.get("slot_number") @@ -589,6 +668,7 @@ def release_slot(request, room_id): status=GateSlot.FILLED, ).first() if slot: + released_slot_number = slot.slot_number slot.gamer = None slot.status = GateSlot.EMPTY slot.filled_at = None @@ -598,6 +678,14 @@ def release_slot(request, room_id): if room.gate_status == Room.OPEN: room.gate_status = Room.GATHERING room.save() + _retract_prior_event( + room, request.user, (GameEvent.SLOT_FILLED,), + slot_number=released_slot_number, + ) + record(room, GameEvent.SLOT_RELEASED, actor=request.user, + slot_number=released_slot_number, token_type=Token.CARTE, + token_display=dict(Token.TOKEN_TYPE_CHOICES).get( + Token.CARTE, "Carte Blanche")) _notify_gate_update(room_id) return redirect("epic:gatekeeper", room_id=room_id) diff --git a/src/static_src/scss/_button-pad.scss b/src/static_src/scss/_button-pad.scss index 8851e85..b2d1e89 100644 --- a/src/static_src/scss/_button-pad.scss +++ b/src/static_src/scss/_button-pad.scss @@ -8,6 +8,7 @@ text-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.5); border: 0.15rem solid rgba(var(--priUser), 1); border-radius: 50%; + font-family: 'Arial', sans-serif; font-weight: 700; font-size: 0.63rem; text-transform: uppercase;