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:
Disco DeDisco
2026-05-26 17:55:26 -04:00
parent c0f4711589
commit 4ddc0f810c
5 changed files with 381 additions and 12 deletions

View File

@@ -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)