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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user