feat: Token.BAND (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO is_staff coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no current_room tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". fa-ring icon across all 4 surfaces (Game Kit applet #id_kit_wristband between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both apps.epic.models.select_token (room gatekeeper) + apps.gameboard.models._select_my_sea_token (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. debit_token + debit_my_sea_token treat BAND same as PASS: slot marked FILLED w. debited_token_type=BAND, token row preserved, current_room untouched, equipped_trinket unchanged. View contexts (gameboard, toggle_game_applets, _game_kit_context, wallet, toggle_wallet_applets) pass a band key — universal lookup, NO is_staff filter. Migration lyric/0007_alter_token_token_type — choices-only AlterField. TDD — 5 FTs in test_trinket_wristband.py (test_band_not_auto_equipped_after_award, test_band_tooltip_renders_full_prose, test_band_uses_fa_ring_icon, test_equipped_band_shows_equipped_mini_tooltip, test_equipped_band_shows_doff_active_don_disabled); 4 tooltip UTs (BandTokenTooltipTest); 5 model ITs (BandTokenAdminAwardTest — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (test_returns_band_when_held_and_no_pass, test_pass_still_wins_over_band_for_staff); 1 debit IT (test_debit_band_does_not_consume_or_unequip). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -48,6 +48,25 @@ class DebitTokenTest(TestCase):
|
||||
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||||
self.assertEqual(self.slot.gamer, self.owner)
|
||||
|
||||
def test_debit_band_does_not_consume_or_unequip(self):
|
||||
"""BAND mirrors PASS — fills the slot, but never deleted, never
|
||||
gets `current_room` set, and stays equipped (debit_token's PASS
|
||||
branch is the model). The wallet should keep showing the BAND
|
||||
after the user enters a gate w. it."""
|
||||
band = Token.objects.create(user=self.owner, token_type=Token.BAND)
|
||||
self.owner.equipped_trinket = band
|
||||
self.owner.save(update_fields=["equipped_trinket"])
|
||||
debit_token(self.owner, self.slot, band)
|
||||
self.assertTrue(Token.objects.filter(pk=band.pk).exists())
|
||||
band.refresh_from_db()
|
||||
self.assertIsNone(band.current_room_id)
|
||||
self.owner.refresh_from_db()
|
||||
self.assertEqual(self.owner.equipped_trinket_id, band.pk)
|
||||
self.slot.refresh_from_db()
|
||||
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||||
self.assertEqual(self.slot.gamer, self.owner)
|
||||
self.assertEqual(self.slot.debited_token_type, Token.BAND)
|
||||
|
||||
def test_debit_fills_last_slot_and_opens_gate(self):
|
||||
for i in range(2, 7):
|
||||
gamer = User.objects.create(email=f"g{i}@test.io")
|
||||
@@ -138,6 +157,23 @@ class SelectTokenTest(TestCase):
|
||||
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)."""
|
||||
band = Token.objects.create(user=self.user, token_type=Token.BAND)
|
||||
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."""
|
||||
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)
|
||||
token = select_token(self.user)
|
||||
self.assertEqual(token.pk, pass_token.pk)
|
||||
|
||||
|
||||
class RoomTableStatusTest(TestCase):
|
||||
def setUp(self):
|
||||
|
||||
Reference in New Issue
Block a user