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

@@ -151,30 +151,126 @@ class SelectTokenTest(TestCase):
self.assertIsNone(token)
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.save()
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)
self.assertEqual(token.token_type, Token.PASS)
def test_returns_band_when_held_and_no_pass(self):
"""BAND beats COIN/FREE/TITHE for non-staff (same never-consumed
rationale as PASS — burn the cheaper consumables first)."""
def test_returns_band_when_equipped(self):
"""BAND, like PASS, must be equipped to be picked. Awarded-but-
DOFFed BAND stays in the wallet but doesn't auto-fire."""
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)
self.assertEqual(token.pk, band.pk)
def test_pass_still_wins_over_band_for_staff(self):
"""Staff holding both PASS and BAND get PASS — PASS sits at the top
of the priority chain, BAND slots in below it."""
def test_pass_wins_when_equipped_over_band(self):
"""Equipped slot is the only trinket the picker considers — whichever
the user has DON-ed is the one that fires."""
self.user.is_staff = True
self.user.save()
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
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)
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):
def setUp(self):
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())
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.save()
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.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
self.coin.refresh_from_db()