sprint A.8 gatekeeper: token deposit ↔ withdraw redact-pair on the room scroll — TDD
User-spec 2026-05-26 sprint A.8 push into room.html — first thread: symmetric "redact-and-replace" logging in the gatekeeper, mirroring how Sig Select already does it for embody/disembody. The deposit path already recorded SLOT_FILLED on the room scroll, but the return/release paths emitted nothing — so the user could withdraw a token without any provenance trail. Now every state transition records a new GameEvent AND marks its most-recent unretracted counterpart as `data.retracted=True`, which the existing scroll template renders strikethrough + Redact-tagged.
Drama (apps/drama/models.py):
- Unified withdraw prose. SLOT_RETURNED + SLOT_RELEASED now both render as "withdraws {poss} {token} from slot {#}." (mirrors SLOT_FILLED's "deposits a {token} for slot {#} (expires in N days).") so the redact-pair reads as a clean visual mirror in the scroll. Token-display + slot-number fields match the deposit event's shape. The verb distinction stays in the data layer (SLOT_RETURNED = full token return, SLOT_RELEASED = per-slot CARTE release without surrendering the CARTE itself); the prose collapses to one shape so the user sees consistency.
Epic views (apps/epic/views.py):
- New `_retract_prior_event(room, actor, verbs, slot_number=None)` helper centralizes the redact-pair pattern that was inlined three times in sig_ready. Takes a verb tuple so a deposit can retract either SLOT_RETURNED or SLOT_RELEASED (both represent a withdraw of the slot in question). No-op if no matching unretracted prior exists. Slot-number filter via `data__slot_number` so multiple deposits from the same actor (different slots) don't shadow each other.
- `confirm_token` (deposit) — both paths (CARTE per-slot + non-CARTE confirmation) now call _retract_prior_event(SLOT_RETURNED|SLOT_RELEASED) before recording the new SLOT_FILLED. Re-deposit after a withdraw strikes the prior withdraw entry.
- `return_token` (CARTE full return) — snapshots the affected slot_numbers BEFORE the bulk update so each gets its own retract + SLOT_RETURNED record. Per-slot symmetry confirmed w. user: a 6-slot CARTE return produces 6 new "withdraws" entries + the corresponding 6 deposits become strikethrough.
- `return_token` (non-CARTE single-slot return) — emits SLOT_RETURNED + retracts prior SLOT_FILLED only when the slot was FILLED (was_filled guard); a RESERVED→EMPTY cancel never recorded a SLOT_FILLED, so no redact-pair fires.
- `release_slot` (per-slot CARTE release without surrendering CARTE) — emits SLOT_RELEASED + retracts the prior SLOT_FILLED on that slot.
Tests:
- `apps/drama/tests/integrated/test_models.py`: two existing prose UTs updated to assert the new unified withdraw shape (token + slot + pronoun) instead of the legacy "withdraws from the gate" / "releases slot N" wordings.
- `apps/epic/tests/integrated/test_token_redact_pair.py` (NEW, 10 ITs):
- `TokenWithdrawRedactPairTest` (4) — non-CARTE return emits SLOT_RETURNED, retracts prior SLOT_FILLED, renders w. unified prose, NO entry for RESERVED-only cancels.
- `TokenRedepositAfterWithdrawTest` (2) — confirm_token after a prior withdraw retracts the prior SLOT_RETURNED and the new SLOT_FILLED starts unretracted.
- `CarteFullReturnPerSlotRedactPairTest` (2) — 3-slot CARTE return emits 3 SLOT_RETURNED entries (one per slot); each slot's prior SLOT_FILLED gets retracted.
- `ReleaseSlotRedactPairTest` (2) — per-slot release emits SLOT_RELEASED + retracts that slot's SLOT_FILLED.
Existing scroll template (`templates/core/_partials/_scroll.html`) needs no change — `event.struck` already drives `data-label="{redact|frame}"` + the `.struck` strikethrough class. CSS already in place in `_billboard.scss:430-433`. The Frame/Redact filter checkbox in `templates/apps/billboard/scroll.html` already toggles visibility per label w. localStorage persistence per room.
Pre-existing in `git status`, bundled per project commit-everything rule:
- `static_src/scss/_button-pad.scss` — single blank-line whitespace tweak (no semantic change).
All 1350 IT+UT green (1340 before + 10 new).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -71,10 +71,23 @@ class GameEvent(models.Model):
|
|||||||
return f"deposits a {token} for slot {slot} (expires in {days} days)."
|
return f"deposits a {token} for slot {slot} (expires in {days} days)."
|
||||||
if self.verb == self.SLOT_RESERVED:
|
if self.verb == self.SLOT_RESERVED:
|
||||||
return "reserves a seat"
|
return "reserves a seat"
|
||||||
if self.verb == self.SLOT_RETURNED:
|
if self.verb in (self.SLOT_RETURNED, self.SLOT_RELEASED):
|
||||||
return "withdraws from the gate"
|
# Symmetric counterpart to SLOT_FILLED's "deposits a {token} for
|
||||||
if self.verb == self.SLOT_RELEASED:
|
# slot {#} …" — same shape so the redact-pair (strikethrough on
|
||||||
return f"releases slot {d.get('slot_number', '?')}"
|
# 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:
|
if self.verb == self.ROOM_CREATED:
|
||||||
# First scroll log on a fresh room — system-authored greeting
|
# First scroll log on a fresh room — system-authored greeting
|
||||||
# (actor=None upstream). Format intentionally drops the actor
|
# (actor=None upstream). Format intentionally drops the actor
|
||||||
|
|||||||
@@ -181,13 +181,33 @@ class GameEventModelTest(TestCase):
|
|||||||
event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||||
self.assertEqual(event.to_prose(), "reserves a seat")
|
self.assertEqual(event.to_prose(), "reserves a seat")
|
||||||
|
|
||||||
def test_slot_returned_prose(self):
|
def test_slot_returned_prose_includes_token_and_slot(self):
|
||||||
event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user)
|
# Sprint A.8 (2026-05-26): SLOT_RETURNED now mirrors SLOT_FILLED's
|
||||||
self.assertEqual(event.to_prose(), "withdraws from the gate")
|
# 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):
|
def test_slot_released_prose_uses_unified_withdraw_shape(self):
|
||||||
event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3)
|
# SLOT_RELEASED (per-slot CARTE release) shares the unified
|
||||||
self.assertIn("slot 3", event.to_prose())
|
# 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):
|
def test_invite_sent_prose(self):
|
||||||
event = record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
event = record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
||||||
|
|||||||
247
src/apps/epic/tests/integrated/test_token_redact_pair.py
Normal file
247
src/apps/epic/tests/integrated/test_token_redact_pair.py
Normal file
@@ -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)
|
||||||
@@ -29,6 +29,27 @@ from apps.lyric.models import Token
|
|||||||
RESERVE_TIMEOUT = timedelta(seconds=60)
|
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):
|
def _notify_gate_update(room_id):
|
||||||
async_to_sync(get_channel_layer().group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
f'room_{room_id}',
|
f'room_{room_id}',
|
||||||
@@ -496,6 +517,15 @@ def confirm_token(request, room_id):
|
|||||||
if int(slot_number) > carte.slots_claimed:
|
if int(slot_number) > carte.slots_claimed:
|
||||||
carte.slots_claimed = int(slot_number)
|
carte.slots_claimed = int(slot_number)
|
||||||
carte.save()
|
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,
|
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||||
slot_number=int(slot_number), token_type=Token.CARTE,
|
slot_number=int(slot_number), token_type=Token.CARTE,
|
||||||
token_display=carte.get_token_type_display(),
|
token_display=carte.get_token_type_display(),
|
||||||
@@ -514,6 +544,13 @@ def confirm_token(request, room_id):
|
|||||||
token = select_token(request.user)
|
token = select_token(request.user)
|
||||||
if token:
|
if token:
|
||||||
debit_token(request.user, slot, 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,
|
record(room, GameEvent.SLOT_FILLED, actor=request.user,
|
||||||
slot_number=slot.slot_number, token_type=token.token_type,
|
slot_number=slot.slot_number, token_type=token.token_type,
|
||||||
token_display=token.get_token_type_display(),
|
token_display=token.get_token_type_display(),
|
||||||
@@ -526,11 +563,20 @@ def confirm_token(request, room_id):
|
|||||||
def return_token(request, room_id):
|
def return_token(request, room_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
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(
|
carte = request.user.tokens.filter(
|
||||||
token_type=Token.CARTE, current_room=room
|
token_type=Token.CARTE, current_room=room
|
||||||
).first()
|
).first()
|
||||||
if carte:
|
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(
|
room.gate_slots.filter(
|
||||||
debited_token_type=Token.CARTE, gamer=request.user
|
debited_token_type=Token.CARTE, gamer=request.user
|
||||||
).update(
|
).update(
|
||||||
@@ -541,6 +587,13 @@ def return_token(request, room_id):
|
|||||||
carte.slots_claimed = 0
|
carte.slots_claimed = 0
|
||||||
carte.save()
|
carte.save()
|
||||||
request.session.pop("kit_token_id", None)
|
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)
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
slot = room.gate_slots.filter(
|
slot = room.gate_slots.filter(
|
||||||
@@ -548,6 +601,10 @@ def return_token(request, room_id):
|
|||||||
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
status__in=[GateSlot.RESERVED, GateSlot.FILLED],
|
||||||
).first()
|
).first()
|
||||||
if slot:
|
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.status == GateSlot.FILLED:
|
||||||
if slot.debited_token_type == Token.COIN:
|
if slot.debited_token_type == Token.COIN:
|
||||||
coin = request.user.tokens.filter(
|
coin = request.user.tokens.filter(
|
||||||
@@ -564,6 +621,7 @@ def return_token(request, room_id):
|
|||||||
expires_at=slot.debited_token_expires_at,
|
expires_at=slot.debited_token_expires_at,
|
||||||
)
|
)
|
||||||
request.session.pop("kit_token_id", None)
|
request.session.pop("kit_token_id", None)
|
||||||
|
was_filled = slot.status == GateSlot.FILLED
|
||||||
slot.gamer = None
|
slot.gamer = None
|
||||||
slot.status = GateSlot.EMPTY
|
slot.status = GateSlot.EMPTY
|
||||||
slot.reserved_at = None
|
slot.reserved_at = None
|
||||||
@@ -571,13 +629,34 @@ def return_token(request, room_id):
|
|||||||
slot.debited_token_type = None
|
slot.debited_token_type = None
|
||||||
slot.debited_token_expires_at = None
|
slot.debited_token_expires_at = None
|
||||||
slot.save()
|
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)
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def release_slot(request, room_id):
|
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":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
slot_number = request.POST.get("slot_number")
|
slot_number = request.POST.get("slot_number")
|
||||||
@@ -589,6 +668,7 @@ def release_slot(request, room_id):
|
|||||||
status=GateSlot.FILLED,
|
status=GateSlot.FILLED,
|
||||||
).first()
|
).first()
|
||||||
if slot:
|
if slot:
|
||||||
|
released_slot_number = slot.slot_number
|
||||||
slot.gamer = None
|
slot.gamer = None
|
||||||
slot.status = GateSlot.EMPTY
|
slot.status = GateSlot.EMPTY
|
||||||
slot.filled_at = None
|
slot.filled_at = None
|
||||||
@@ -598,6 +678,14 @@ def release_slot(request, room_id):
|
|||||||
if room.gate_status == Room.OPEN:
|
if room.gate_status == Room.OPEN:
|
||||||
room.gate_status = Room.GATHERING
|
room.gate_status = Room.GATHERING
|
||||||
room.save()
|
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)
|
_notify_gate_update(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
text-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.5);
|
text-shadow: 0 0 0.25rem rgba(0, 0, 0, 0.5);
|
||||||
border: 0.15rem solid rgba(var(--priUser), 1);
|
border: 0.15rem solid rgba(var(--priUser), 1);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.63rem;
|
font-size: 0.63rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
Reference in New Issue
Block a user