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