diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 385a5e6..c813aad 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -113,16 +113,35 @@ def create_gate_slots(sender, instance, created, **kwargs): def select_token(user): - if user.is_staff: - pass_token = user.tokens.filter(token_type=Token.PASS).first() - if pass_token: - return pass_token - band = user.tokens.filter(token_type=Token.BAND).first() - if band: - return band - coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first() - if coin: - return coin + """Pick a token for `drop_token`'s rails-click flow (no explicit + kit-bag choice). Equip-gated: trinkets (PASS/BAND/COIN) must be DON-ed + to fire; CARTE is opt-in only (kit-bag click sets a `token_id` POST + param that bypasses this picker). No equipped trinket OR equipped + trinket invalid for this gate → fall back to FREE (FEFO) → TITHE → None. + + Bug 2026-05-21 fix: previous flat-priority chain (PASS → BAND → COIN + → FREE → TITHE, regardless of equip state) silently consumed a DOFFed + COIN — user saw nothing change in the wallet ("free for all" symptom). + 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( token_type=Token.FREE, expires_at__gt=timezone.now(), diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 8d99513..fc664fc 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -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") diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index ffa2b5a..d3a5779 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -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() diff --git a/src/apps/gameboard/models.py b/src/apps/gameboard/models.py index 83e0457..13c2d15 100644 --- a/src/apps/gameboard/models.py +++ b/src/apps/gameboard/models.py @@ -114,36 +114,41 @@ class MySeaDraw(models.Model): def _select_my_sea_token(user): - """Token-picker for the my-sea gatekeeper. Mirrors `apps.epic.models. - select_token` priority chain (PASS → COIN → FREE → TITHE) w. two - iter-6a tweaks per user spec 2026-05-20: + """Token-picker for the my-sea gatekeeper. Same equip-gated semantics + as `apps.epic.models.select_token` (trinket must be DON-ed) w. two + my-sea-specific rules: - - **CARTE excluded.** The door-spell trinket isn't a valid token - for paying my-sea draws. + - **CARTE excluded.** Even equipped, CARTE never auto-picks here — + `debit_my_sea_token` would raise ValueError. Fall through to FREE. - **COIN cooldown-respecting.** After a my-sea PAID DRAW debits a - COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h` (not - the room's 7-day window). A cooldown'd COIN is unavailable to - this picker; standard `select_token` ignores `next_ready_at` so - room logic stays untouched (intentional: my-sea is the only path - that respects this cooldown). + COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h`. A + cooldown'd COIN — even equipped — falls through to FREE. The + cooldown is my-sea-specific; room `select_token` ignores it. + + 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 now = timezone.now() - if user.is_staff: - pass_token = user.tokens.filter(token_type=Token.PASS).first() - if pass_token: - return pass_token - band = user.tokens.filter(token_type=Token.BAND).first() - if band: - return band - coin = user.tokens.filter( - token_type=Token.COIN, current_room__isnull=True, - ).filter( - Q(next_ready_at__isnull=True) | Q(next_ready_at__lte=now), - ).first() - if coin: - return coin + # Query fresh from user's tokens (not via cached FK descriptor) — see + # `apps.epic.models.select_token` for the rationale. + 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: + coin_ready = ( + 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( token_type=Token.FREE, expires_at__gt=now, ).order_by("expires_at").first() diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index aa65262..7efbe15 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -1703,8 +1703,12 @@ class SelectMySeaTokenTest(TestCase): self.user = User.objects.create(email="selecttok@test.io") # New-user post_save signal auto-creates COIN + FREE tokens # (`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.refresh_from_db() def test_carte_is_excluded(self): from apps.gameboard.models import _select_my_sea_token @@ -1720,15 +1724,97 @@ class SelectMySeaTokenTest(TestCase): self.assertIsNone(_select_my_sea_token(self.user)) 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.gameboard.models import _select_my_sea_token self.user.is_staff = True self.user.save(update_fields=["is_staff"]) pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS) 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) +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): """Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics.""" diff --git a/src/functional_tests/test_game_room_gatekeeper.py b/src/functional_tests/test_game_room_gatekeeper.py index 01d2ddd..e4336d9 100644 --- a/src/functional_tests/test_game_room_gatekeeper.py +++ b/src/functional_tests/test_game_room_gatekeeper.py @@ -507,10 +507,15 @@ class TokenPriorityTest(FunctionalTest): ) 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.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"]) # 2. Drops token, confirms as normal self.browser.get(self.gate_url) self.wait_for(