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]]
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-21 12:33:09 -04:00
parent 0f60c73f3b
commit 99ffdb3943
14 changed files with 312 additions and 6 deletions

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-05-21 15:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0006_user_significator_user_significator_reversed'),
]
operations = [
migrations.AlterField(
model_name='token',
name='token_type',
field=models.CharField(choices=[('coin', 'Coin-on-a-String'), ('Free', 'Free Token'), ('tithe', 'Tithe Token'), ('pass', 'Backstage Pass'), ('band', 'Wristband'), ('carte', 'Carte Blanche')], max_length=8),
),
]

View File

@@ -232,12 +232,14 @@ class Token(models.Model):
FREE = "Free"
TITHE = "tithe"
PASS = "pass"
BAND = "band"
CARTE = "carte"
TOKEN_TYPE_CHOICES = [
(COIN, "Coin-on-a-String"),
(FREE, "Free Token"),
(TITHE, "Tithe Token"),
(PASS, "Backstage Pass"),
(BAND, "Wristband"),
(CARTE, "Carte Blanche"),
]
@@ -276,7 +278,7 @@ class Token(models.Model):
def tooltip_description(self):
if self.token_type in (self.COIN, self.FREE):
return "Admit 1 Entry"
if self.token_type == self.PASS:
if self.token_type in (self.PASS, self.BAND):
return "Admit All Entry"
if self.token_type == self.TITHE:
return "+ Writ bonus"
@@ -285,7 +287,7 @@ class Token(models.Model):
return ""
def tooltip_expiry(self):
if self.token_type in (self.COIN, self.PASS, self.CARTE):
if self.token_type in (self.COIN, self.PASS, self.BAND, self.CARTE):
if self.token_type == self.COIN and self.next_ready_at:
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
return "no expiry"
@@ -306,6 +308,8 @@ class Token(models.Model):
return "a spot of good fortune"
if self.token_type == self.PASS:
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
if self.token_type == self.BAND:
return "Unlimited free entry (BYOB)"
if self.token_type == self.CARTE:
return "No, I\u2019m afraid we\u2019ll be taking over from here."
return None

View File

@@ -360,6 +360,45 @@ class PassTokenStaffOnlyGuardTest(TestCase):
self.assertEqual(token.token_type, Token.TITHE)
class BandTokenAdminAwardTest(TestCase):
"""BAND is the non-admin variant of PASS. Contract: NOT auto-granted by
the post_save signal (unlike PASS for staff, or COIN/FREE for everyone),
so admin can hand-mint it for any user via the Django admin without
every fresh account spawning one. No `is_staff` coupling — staff or
non-staff can hold one — so no model-layer guard either."""
def test_band_not_auto_granted_on_user_creation(self):
user = User.objects.create(email="newgamer@test.io")
self.assertFalse(user.tokens.filter(token_type=Token.BAND).exists())
def test_band_not_auto_granted_on_staff_creation(self):
"""Staff get PASS, not BAND, on creation. BAND is admin-awarded
post-hoc to either staff or non-staff (no role-coupling like PASS)."""
staff = User.objects.create(email="admin@test.io", is_staff=True)
self.assertFalse(staff.tokens.filter(token_type=Token.BAND).exists())
def test_admin_can_award_band_to_non_staff(self):
non_staff = User.objects.create(email="gamer@test.io")
token = Token.objects.create(user=non_staff, token_type=Token.BAND)
self.assertEqual(token.token_type, Token.BAND)
def test_admin_can_award_band_to_staff(self):
staff = User.objects.create(email="admin@test.io", is_staff=True)
token = Token.objects.create(user=staff, token_type=Token.BAND)
self.assertEqual(token.token_type, Token.BAND)
def test_band_not_auto_equipped_after_award(self):
"""Award diverges from PASS — admin-minted BAND lands in the wallet
but the user must DON it manually (`equipped_trinket` stays at its
post-signal default of COIN for non-staff)."""
non_staff = User.objects.create(email="bro@test.io")
coin = non_staff.tokens.get(token_type=Token.COIN)
band = Token.objects.create(user=non_staff, token_type=Token.BAND)
non_staff.refresh_from_db()
self.assertEqual(non_staff.equipped_trinket, coin)
self.assertNotEqual(non_staff.equipped_trinket, band)
class EquippedDeckTest(TestCase):
def test_new_user_gets_earthman_as_default_deck(self):
from apps.epic.models import DeckVariant

View File

@@ -56,6 +56,31 @@ class PassTokenTooltipTest(SimpleTestCase):
self.assertIn("no expiry", self.token.tooltip_text())
class BandTokenTooltipTest(SimpleTestCase):
"""Wristband mirrors PASS's "Admit All Entry" / never-expires shape but
is non-admin (no `is_staff` gate). Tooltip prose is the only place the
two diverge — different title + shoptalk."""
def setUp(self):
self.token = Token()
self.token.token_type = Token.BAND
self.token.expires_at = None
self.token.next_ready_at = None
def test_tooltip_contains_name(self):
self.assertIn("Wristband", self.token.tooltip_text())
def test_tooltip_contains_admit_all(self):
self.assertIn("Admit All Entry", self.token.tooltip_text())
def test_tooltip_contains_shoptalk(self):
self.assertIn("Unlimited free entry", self.token.tooltip_text())
self.assertIn("BYOB", self.token.tooltip_text())
def test_tooltip_contains_no_expiry(self):
self.assertIn("no expiry", self.token.tooltip_text())
class CarteTooltipTest(SimpleTestCase):
def setUp(self):
self.token = Token()