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]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user