fix: gate token-picker now equip-gated — User.equipped_trinket is the sole opt-in for trinket-as-token use at BOTH gatekeepers (/gameboard/room/<id>/gate/ + /gameboard/my-sea/gate/). Old flat-priority chain (PASS→BAND→COIN→FREE→TITHE) silently consumed a DOFFed-but-owned COIN when the user clicked the rails — current_room advanced, no inventory decrement, wallet looked unchanged. User-reported 2026-05-21 as "free for all" admit when no trinket equipped. Root cause: select_token + _select_my_sea_token ignored equipped_trinket_id entirely + just grabbed the highest-priority owned token regardless of equip state, making the equip slot a decorative no-op. **Fix**: both pickers now start from user.equipped_trinket_id; equipped PASS (staff)/BAND/COIN-with-no-current-room → return it; equipped CARTE → fall through (CARTE is opt-in via kit-bag click that sets token_id POST param routed through drop_token's explicit branch, NOT select_token); my-sea additionally checks COIN cooldown (next_ready_at <= now); no equipped trinket OR equipped trinket invalid → FREE (FEFO) → TITHE → None. **Fresh-query defense**: pickers query user.tokens.filter(pk=user.equipped_trinket_id).first() instead of the cached user.equipped_trinket FK descriptor — descriptor goes stale across mid-request state changes + bites tests where tokens.all().delete() triggers SET_NULL cascade but the Python object stays unrefreshed (SQLite reuses deleted PKs so a coincidentally-matching new token slips through). TDD — new SelectTokenEquipGatedTest (7 ITs) + SelectMySeaTokenEquipGatedTest (6 ITs) pin: skip-unequipped-COIN → FREE; skip-unequipped-BAND → TITHE; no equip + no consumables → None; CARTE equipped → falls through; equipped-COIN-in-use-elsewhere falls through; staff with unequipped PASS falls through; my-sea cooldown-COIN-equipped falls through. **Existing tests updated** (5 cases pinned the old flat-priority semantic + needed equipping explicit before assertion): SelectTokenTest.test_returns_pass_for_staff + test_returns_band_when_equipped + test_pass_wins_when_equipped_over_band + SelectMySeaTokenTest.test_pass_wins_priority_for_staff (now equip PASS first); ConfirmTokenPriorityViewTest.test_pass_not_consumed_and_coin_not_leased + TokenPriorityTest.test_staff_backstage_pass_bypasses_token_cost (FT) now DON the PASS before clicking rails. SelectMySeaTokenTest.setUp adds refresh_from_db() after tokens.all().delete() so the cascade SET_NULL on equipped_trinket_id is reflected in the Python object. 1160 IT/UT + 5 TokenPriority FTs green. Trap captured: [[feedback-equip-slot-gates-trinket-use]]
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-21 13:56:59 -04:00
parent f59c1af89a
commit 8dd4347dbe
6 changed files with 261 additions and 43 deletions

View File

@@ -113,16 +113,35 @@ def create_gate_slots(sender, instance, created, **kwargs):
def select_token(user): def select_token(user):
if user.is_staff: """Pick a token for `drop_token`'s rails-click flow (no explicit
pass_token = user.tokens.filter(token_type=Token.PASS).first() kit-bag choice). Equip-gated: trinkets (PASS/BAND/COIN) must be DON-ed
if pass_token: to fire; CARTE is opt-in only (kit-bag click sets a `token_id` POST
return pass_token param that bypasses this picker). No equipped trinket OR equipped
band = user.tokens.filter(token_type=Token.BAND).first() trinket invalid for this gate → fall back to FREE (FEFO) → TITHE → None.
if band:
return band Bug 2026-05-21 fix: previous flat-priority chain (PASS → BAND → COIN
coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first() → FREE → TITHE, regardless of equip state) silently consumed a DOFFed
if coin: COIN — user saw nothing change in the wallet ("free for all" symptom).
return coin Equip slot now gates trinket use entirely. See [[feedback-equip-slot-
gates-trinket-use]] for the rationale.
"""
# Query the trinket fresh from the user's tokens (not via the cached
# FK descriptor) — defensive against stale Token state from earlier
# in the request lifecycle + cheap filter on the owned-set so a
# dangling FK to a deleted token resolves to None instead of crashing.
if user.equipped_trinket_id is not None:
trinket = user.tokens.filter(pk=user.equipped_trinket_id).first()
else:
trinket = None
if trinket is not None:
if trinket.token_type == Token.PASS and user.is_staff:
return trinket
if trinket.token_type == Token.BAND:
return trinket
if trinket.token_type == Token.COIN and trinket.current_room_id is None:
return trinket
# CARTE excluded — opt-in via explicit kit-bag click; idle CARTE-
# holders get FREE/TITHE fallback.
free = user.tokens.filter( free = user.tokens.filter(
token_type=Token.FREE, token_type=Token.FREE,
expires_at__gt=timezone.now(), expires_at__gt=timezone.now(),

View File

@@ -151,30 +151,126 @@ class SelectTokenTest(TestCase):
self.assertIsNone(token) self.assertIsNone(token)
def test_returns_pass_for_staff(self): def test_returns_pass_for_staff(self):
"""PASS must be equipped to be picked — DON-ing it is the user's
opt-in to trinket use (parity w. COIN's auto-equip default)."""
self.user.is_staff = True self.user.is_staff = True
self.user.save() self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS) pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
self.user.equipped_trinket = pass_token
self.user.save(update_fields=["equipped_trinket"])
token = select_token(self.user) token = select_token(self.user)
self.assertEqual(token.token_type, Token.PASS) self.assertEqual(token.token_type, Token.PASS)
def test_returns_band_when_held_and_no_pass(self): def test_returns_band_when_equipped(self):
"""BAND beats COIN/FREE/TITHE for non-staff (same never-consumed """BAND, like PASS, must be equipped to be picked. Awarded-but-
rationale as PASS — burn the cheaper consumables first).""" DOFFed BAND stays in the wallet but doesn't auto-fire."""
band = Token.objects.create(user=self.user, token_type=Token.BAND) band = Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = band
self.user.save(update_fields=["equipped_trinket"])
token = select_token(self.user) token = select_token(self.user)
self.assertEqual(token.pk, band.pk) self.assertEqual(token.pk, band.pk)
def test_pass_still_wins_over_band_for_staff(self): def test_pass_wins_when_equipped_over_band(self):
"""Staff holding both PASS and BAND get PASS — PASS sits at the top """Equipped slot is the only trinket the picker considers — whichever
of the priority chain, BAND slots in below it.""" the user has DON-ed is the one that fires."""
self.user.is_staff = True self.user.is_staff = True
self.user.save() self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS) pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
Token.objects.create(user=self.user, token_type=Token.BAND) Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = pass_token
self.user.save(update_fields=["equipped_trinket"])
token = select_token(self.user) token = select_token(self.user)
self.assertEqual(token.pk, pass_token.pk) self.assertEqual(token.pk, pass_token.pk)
class SelectTokenEquipGatedTest(TestCase):
"""The trinket slot is the user's opt-in to trinket-as-token use. A DOFFed
trinket (PASS/BAND/COIN) stays in the wallet but is invisible to the gate
picker — clicking the rails falls back to FREE (FEFO) → TITHE → None.
CARTE is never auto-picked even when equipped: it's opt-in via the kit-
bag click flow, which routes through `drop_token`'s explicit `token_id`
POST param (not `select_token`).
Bug 2026-05-21 (user-reported): no equipped trinket + only FREE/TITHE
available → "free for all" rails admit because the old flat-priority
chain still grabbed an owned-but-DOFFed COIN, advanced its current_room
silently, and never decremented anything visible. New semantics: the
equip slot gates trinket use entirely."""
def setUp(self):
self.user = User.objects.create(email="equip@test.io")
# Wipe auto-COIN + auto-FREE + the auto-equip; tests seed precisely.
self.user.tokens.all().delete()
self.user.refresh_from_db() # SET_NULL on equipped_trinket fired
def test_skips_unequipped_coin_and_returns_free(self):
Token.objects.create(user=self.user, token_type=Token.COIN)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.assertEqual(select_token(self.user).pk, free.pk)
def test_skips_unequipped_coin_and_returns_tithe_when_no_free(self):
Token.objects.create(user=self.user, token_type=Token.COIN)
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
self.assertEqual(select_token(self.user).pk, tithe.pk)
def test_returns_none_when_no_equip_and_no_consumables(self):
Token.objects.create(user=self.user, token_type=Token.COIN)
Token.objects.create(user=self.user, token_type=Token.BAND)
self.assertIsNone(select_token(self.user))
def test_carte_equipped_falls_through_to_free(self):
"""CARTE is opt-in via kit-bag's explicit click — never auto-picked
by select_token even when equipped (the rails fallback for an idle
CARTE-holder is FREE/TITHE, not CARTE itself)."""
carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.user.equipped_trinket = carte
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(select_token(self.user).pk, free.pk)
def test_equipped_coin_wins_over_unequipped_band(self):
"""Equip slot is exclusive — the DON-ed trinket is the only one the
picker considers among trinkets, regardless of priority rank."""
coin = Token.objects.create(user=self.user, token_type=Token.COIN)
Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = coin
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(select_token(self.user).pk, coin.pk)
def test_equipped_coin_in_use_elsewhere_falls_through_to_free(self):
"""Defensive: equipped + in-use COIN shouldn't occur (debit_token
auto-unequips on consumption) but if it does, treat as no-equip."""
other_room = Room.objects.create(name="Elsewhere", owner=self.user)
coin = Token.objects.create(
user=self.user, token_type=Token.COIN, current_room=other_room,
)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.user.equipped_trinket = coin
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(select_token(self.user).pk, free.pk)
def test_staff_with_unequipped_pass_falls_through_to_free(self):
"""Even staff must DON the PASS — the auto-equip on user creation
is the convenience default, NOT a special-case bypass of the rule."""
self.user.is_staff = True
self.user.save(update_fields=["is_staff"])
Token.objects.create(user=self.user, token_type=Token.PASS)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.assertEqual(select_token(self.user).pk, free.pk)
class RoomTableStatusTest(TestCase): class RoomTableStatusTest(TestCase):
def setUp(self): def setUp(self):
self.owner = User.objects.create(email="founder@test.io") self.owner = User.objects.create(email="founder@test.io")

View File

@@ -398,9 +398,16 @@ class ConfirmTokenPriorityViewTest(TestCase):
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists()) self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
def test_pass_not_consumed_and_coin_not_leased(self): def test_pass_not_consumed_and_coin_not_leased(self):
"""Equipped PASS picked over the still-equipped-by-default COIN —
confirm leaves PASS untouched + doesn't lease the COIN (PASS is
never-consumed; COIN stays free for a future room). The equip
slot is the precondition; DON-ing PASS swaps it in for the auto-
equipped COIN."""
self.gamer.is_staff = True self.gamer.is_staff = True
self.gamer.save() self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS) pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.gamer.equipped_trinket = pass_token
self.gamer.save(update_fields=["equipped_trinket"])
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id})) self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists()) self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db() self.coin.refresh_from_db()

View File

@@ -114,36 +114,41 @@ class MySeaDraw(models.Model):
def _select_my_sea_token(user): def _select_my_sea_token(user):
"""Token-picker for the my-sea gatekeeper. Mirrors `apps.epic.models. """Token-picker for the my-sea gatekeeper. Same equip-gated semantics
select_token` priority chain (PASS → COIN → FREE → TITHE) w. two as `apps.epic.models.select_token` (trinket must be DON-ed) w. two
iter-6a tweaks per user spec 2026-05-20: my-sea-specific rules:
- **CARTE excluded.** The door-spell trinket isn't a valid token - **CARTE excluded.** Even equipped, CARTE never auto-picks here —
for paying my-sea draws. `debit_my_sea_token` would raise ValueError. Fall through to FREE.
- **COIN cooldown-respecting.** After a my-sea PAID DRAW debits a - **COIN cooldown-respecting.** After a my-sea PAID DRAW debits a
COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h` (not COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h`. A
the room's 7-day window). A cooldown'd COIN is unavailable to cooldown'd COIN — even equipped — falls through to FREE. The
this picker; standard `select_token` ignores `next_ready_at` so cooldown is my-sea-specific; room `select_token` ignores it.
room logic stays untouched (intentional: my-sea is the only path
that respects this cooldown). No equipped trinket (or equipped one invalid for this gate) → FREE
(FEFO) → TITHE → None. See [[feedback-equip-slot-gates-trinket-use]].
""" """
from django.db.models import Q
from apps.lyric.models import Token from apps.lyric.models import Token
now = timezone.now() now = timezone.now()
if user.is_staff: # Query fresh from user's tokens (not via cached FK descriptor) — see
pass_token = user.tokens.filter(token_type=Token.PASS).first() # `apps.epic.models.select_token` for the rationale.
if pass_token: if user.equipped_trinket_id is not None:
return pass_token trinket = user.tokens.filter(pk=user.equipped_trinket_id).first()
band = user.tokens.filter(token_type=Token.BAND).first() else:
if band: trinket = None
return band if trinket is not None:
coin = user.tokens.filter( if trinket.token_type == Token.PASS and user.is_staff:
token_type=Token.COIN, current_room__isnull=True, return trinket
).filter( if trinket.token_type == Token.BAND:
Q(next_ready_at__isnull=True) | Q(next_ready_at__lte=now), return trinket
).first() if trinket.token_type == Token.COIN:
if coin: coin_ready = (
return coin trinket.current_room_id is None
and (trinket.next_ready_at is None or trinket.next_ready_at <= now)
)
if coin_ready:
return trinket
# CARTE excluded — debit_my_sea_token would raise.
free = user.tokens.filter( free = user.tokens.filter(
token_type=Token.FREE, expires_at__gt=now, token_type=Token.FREE, expires_at__gt=now,
).order_by("expires_at").first() ).order_by("expires_at").first()

View File

@@ -1703,8 +1703,12 @@ class SelectMySeaTokenTest(TestCase):
self.user = User.objects.create(email="selecttok@test.io") self.user = User.objects.create(email="selecttok@test.io")
# New-user post_save signal auto-creates COIN + FREE tokens # New-user post_save signal auto-creates COIN + FREE tokens
# (`apps.lyric.models`). Wipe them so each test only sees the # (`apps.lyric.models`). Wipe them so each test only sees the
# tokens it explicitly seeds. # tokens it explicitly seeds. Refresh so equipped_trinket_id
# picks up the cascade SET_NULL (otherwise the Python object
# stays stale + select_token's defensive fresh-DB-query finds
# an SQLite-reused-pk Token that "happens" to match).
self.user.tokens.all().delete() self.user.tokens.all().delete()
self.user.refresh_from_db()
def test_carte_is_excluded(self): def test_carte_is_excluded(self):
from apps.gameboard.models import _select_my_sea_token from apps.gameboard.models import _select_my_sea_token
@@ -1720,15 +1724,97 @@ class SelectMySeaTokenTest(TestCase):
self.assertIsNone(_select_my_sea_token(self.user)) self.assertIsNone(_select_my_sea_token(self.user))
def test_pass_wins_priority_for_staff(self): def test_pass_wins_priority_for_staff(self):
"""PASS must be EQUIPPED to be picked — DON-ing it is the user's
opt-in to trinket use. Owned-but-DOFFed PASS stays invisible to
the picker (parity w. all other trinkets under the equip-gated
semantics — see [[feedback-equip-slot-gates-trinket-use]])."""
from apps.lyric.models import Token from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token from apps.gameboard.models import _select_my_sea_token
self.user.is_staff = True self.user.is_staff = True
self.user.save(update_fields=["is_staff"]) self.user.save(update_fields=["is_staff"])
pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
Token.objects.create(user=self.user, token_type=Token.COIN) Token.objects.create(user=self.user, token_type=Token.COIN)
self.user.equipped_trinket = pass_tok
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(_select_my_sea_token(self.user), pass_tok) self.assertEqual(_select_my_sea_token(self.user), pass_tok)
class SelectMySeaTokenEquipGatedTest(TestCase):
"""My-sea variant of `SelectTokenEquipGatedTest`. Same equip-gated
semantics: trinkets must be DON-ed to fire; CARTE never auto-picked;
fall back to FREE → TITHE → None. PLUS my-sea's cooldown check on
COIN (24h after debit_my_sea_token consumes one)."""
def setUp(self):
self.user = User.objects.create(email="myseaequip@test.io")
self.user.tokens.all().delete()
self.user.refresh_from_db()
def test_skips_unequipped_coin_and_returns_free(self):
from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token
Token.objects.create(user=self.user, token_type=Token.COIN)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)
def test_skips_unequipped_band_and_returns_tithe(self):
from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token
Token.objects.create(user=self.user, token_type=Token.BAND)
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
self.assertEqual(_select_my_sea_token(self.user).pk, tithe.pk)
def test_returns_none_when_no_equip_and_no_consumables(self):
from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token
Token.objects.create(user=self.user, token_type=Token.COIN)
self.assertIsNone(_select_my_sea_token(self.user))
def test_equipped_band_returns_band(self):
from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token
band = Token.objects.create(user=self.user, token_type=Token.BAND)
self.user.equipped_trinket = band
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(_select_my_sea_token(self.user).pk, band.pk)
def test_equipped_cooldown_coin_falls_through_to_free(self):
"""Equipped COIN on 24h cooldown shouldn't occur (debit_my_sea_
token auto-unequips), but defensive: if it does, the picker
should respect the cooldown + fall through, not return a stale
COIN that debit_my_sea_token would refuse."""
from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token
coin = Token.objects.create(
user=self.user, token_type=Token.COIN,
next_ready_at=timezone.now() + timedelta(hours=12),
)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.user.equipped_trinket = coin
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)
def test_carte_equipped_falls_through_to_free(self):
"""CARTE is fully excluded from my-sea (debit_my_sea_token raises
ValueError for CARTE). Even equipped, it's never auto-picked here."""
from apps.lyric.models import Token
from apps.gameboard.models import _select_my_sea_token
carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
free = Token.objects.create(
user=self.user, token_type=Token.FREE,
expires_at=timezone.now() + timedelta(days=7),
)
self.user.equipped_trinket = carte
self.user.save(update_fields=["equipped_trinket"])
self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)
class DebitMySeaTokenTest(TestCase): class DebitMySeaTokenTest(TestCase):
"""Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics.""" """Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics."""

View File

@@ -507,10 +507,15 @@ class TokenPriorityTest(FunctionalTest):
) )
def test_staff_backstage_pass_bypasses_token_cost(self): def test_staff_backstage_pass_bypasses_token_cost(self):
# 1. Staff user has a PASS token # 1. Staff user has a PASS token, equipped (DON-ed) — equip slot
# now gates trinket use, so PASS must be the active trinket
# for the gate picker to fire it. Auto-equipped COIN gets
# swapped out.
self.gamer.is_staff = True self.gamer.is_staff = True
self.gamer.save() self.gamer.save()
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS) pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
self.gamer.equipped_trinket = pass_token
self.gamer.save(update_fields=["equipped_trinket"])
# 2. Drops token, confirms as normal # 2. Drops token, confirms as normal
self.browser.get(self.gate_url) self.browser.get(self.gate_url)
self.wait_for( self.wait_for(