super-schizo + super-nomad Notes: auto-grant to superusers; sig unlock; navbar titles — TDD

- drama/models.py: _NOTE_DISPLAY dict; Note.display_title / .display_greeting
  properties; super-schizo → "21st Century" + "Schizoid Man";
  super-nomad → "Howdy," + "Stranger"
- billboard/views.py: _NOTE_META super-schizo/nomad entries with mark_safe
  HTML descriptions ("card-ref"-styled card names), swatch_label "I"/"0",
  no palette_options; swatch_label added to note_items context
- lyric/models.py post_save: new superusers get super-schizo + super-nomad
  Notes automatically; setup_sig_session grants them explicitly too
- epic/models.py _filter_major_unlocks: accepts super-nomad / super-schizo
  as valid unlocks alongside their plain counterparts
- _navbar.html: display_greeting|safe + display_title replace slug|capfirst
- my_notes.html: note-item__image-box--label branch for swatch_label
- _note.scss: .note-item__image-box--label modifier (bold italic, solid border)
- _base.scss: .ord global ordinal superscript class (21st etc.)
- ITs: SuperuserNoteGrantTest (3); SigSelectRenderingTest +2 (super- variants)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-04-28 01:30:02 -04:00
parent 1c2b8f96ab
commit 6ad736413b
11 changed files with 98 additions and 4 deletions

View File

@@ -1,6 +1,7 @@
import json import json
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.html import mark_safe
from django.db.models import Max, Q from django.db.models import Max, Q
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
@@ -100,16 +101,31 @@ _NOTE_META = {
"title": "Stargazer", "title": "Stargazer",
"description": "You saved your first personal sky chart.", "description": "You saved your first personal sky chart.",
"palette_options": _palette_opts(["palette-bardo", "palette-sheol"]), "palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
"swatch_label": None,
}, },
"schizo": { "schizo": {
"title": "Schizo", "title": "Schizo",
"description": "The socius recognizes the line of flight.", "description": "The socius recognizes the line of flight.",
"palette_options": [], "palette_options": [],
"swatch_label": None,
}, },
"nomad": { "nomad": {
"title": "Nomad", "title": "Nomad",
"description": "The socius recognizes the smooth space.", "description": "The socius recognizes the smooth space.",
"palette_options": [], "palette_options": [],
"swatch_label": None,
},
"super-schizo": {
"title": "Schizoid Man",
"description": mark_safe('Admin access granted to <span class="card-ref">I. The Schizo</span> as Significator'),
"palette_options": [],
"swatch_label": "I",
},
"super-nomad": {
"title": "Stranger",
"description": mark_safe('Admin access granted to <span class="card-ref">0. The Nomad</span> as Significator'),
"palette_options": [],
"swatch_label": "0",
}, },
} }
@@ -144,6 +160,7 @@ def my_notes(request):
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug), "title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
"description": _NOTE_META.get(n.slug, {}).get("description", ""), "description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []), "palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"swatch_label": _NOTE_META.get(n.slug, {}).get("swatch_label"),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "", "palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
"is_equipped": active_title is not None and active_title.pk == n.pk, "is_equipped": active_title is not None and active_title.pk == n.pk,
} }

View File

@@ -170,6 +170,15 @@ def record(room, verb, actor=None, **data):
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data) return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
_NOTE_DISPLAY = {
"stargazer": {"greeting": "Welcome,", "title": "Stargazer"},
"schizo": {"greeting": "Welcome,", "title": "Schizo"},
"nomad": {"greeting": "Welcome,", "title": "Nomad"},
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
}
class Note(models.Model): class Note(models.Model):
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
@@ -186,6 +195,14 @@ class Note(models.Model):
def __str__(self): def __str__(self):
return f"{self.user.email}{self.slug}" return f"{self.user.email}{self.slug}"
@property
def display_title(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("title", self.slug.replace("-", " ").title())
@property
def display_greeting(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,")
@classmethod @classmethod
def grant_if_new(cls, user, slug): def grant_if_new(cls, user, slug):
from django.utils import timezone from django.utils import timezone

View File

@@ -499,8 +499,8 @@ def _filter_major_unlocks(cards, user):
return [ return [
c for c in cards c for c in cards
if c.arcana != TarotCard.MAJOR if c.arcana != TarotCard.MAJOR
or (c.number == 0 and "nomad" in earned) or (c.number == 0 and earned & {"nomad", "super-nomad"})
or (c.number == 1 and "schizo" in earned) or (c.number == 1 and earned & {"schizo", "super-schizo"})
] ]

View File

@@ -1071,6 +1071,16 @@ class SigSelectRenderingTest(TestCase):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('data-card-id='), 17) self.assertEqual(response.content.decode().count('data-card-id='), 17)
def test_super_nomad_note_also_unlocks_nomad(self):
Note.objects.create(user=self.gamers[0], slug="super-nomad", earned_at=timezone.now())
response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('data-card-id='), 17)
def test_super_schizo_note_also_unlocks_schizo(self):
Note.objects.create(user=self.gamers[0], slug="super-schizo", earned_at=timezone.now())
response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('data-card-id='), 17)
def test_both_notes_gives_18_sig_cards(self): def test_both_notes_gives_18_sig_cards(self):
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now()) Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now()) Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())

View File

@@ -213,3 +213,7 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs):
instance.save(update_fields=['equipped_trinket', 'equipped_deck']) instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
if earthman: if earthman:
instance.unlocked_decks.add(earthman) instance.unlocked_decks.add(earthman)
if instance.is_superuser:
from apps.drama.models import Note
Note.grant_if_new(instance, "super-schizo")
Note.grant_if_new(instance, "super-nomad")

View File

@@ -144,6 +144,27 @@ class SuperuserTokenCreationTest(TestCase):
) )
class SuperuserNoteGrantTest(TestCase):
def setUp(self):
self.user = User.objects.create_superuser(
email="admin@test.io", password="secret"
)
def test_superuser_gets_super_schizo_note(self):
from apps.drama.models import Note
self.assertTrue(Note.objects.filter(user=self.user, slug="super-schizo").exists())
def test_superuser_gets_super_nomad_note(self):
from apps.drama.models import Note
self.assertTrue(Note.objects.filter(user=self.user, slug="super-nomad").exists())
def test_regular_user_does_not_get_super_notes(self):
from apps.drama.models import Note
regular = User.objects.create(email="regular@test.io")
self.assertFalse(Note.objects.filter(user=regular, slug="super-schizo").exists())
self.assertFalse(Note.objects.filter(user=regular, slug="super-nomad").exists())
class WalletTooltipTest(TestCase): class WalletTooltipTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="wallet@test.io") self.user = User.objects.create(email="wallet@test.io")

View File

@@ -20,6 +20,7 @@ from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_K
from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.backends.db import SessionStore
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from apps.drama.models import Note
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
from apps.lyric.models import User from apps.lyric.models import User
@@ -67,6 +68,8 @@ class Command(BaseCommand):
user.equipped_deck = None user.equipped_deck = None
user.save() user.save()
user.unlocked_decks.add(earthman) user.unlocked_decks.add(earthman)
Note.grant_if_new(user, "super-schizo")
Note.grant_if_new(user, "super-nomad")
users.append(user) users.append(user)
# ── Room ───────────────────────────────────────────────────────────── # ── Room ─────────────────────────────────────────────────────────────

View File

@@ -557,6 +557,15 @@ body {
opacity: 0.6; opacity: 0.6;
} }
// Ordinal superscript: 21st, 2nd, 3rd etc. — matches .tt-ord but globally available.
.ord {
font-size: 0.6em;
vertical-align: 0.25em;
line-height: 0;
margin-left: -0.1em;
letter-spacing: 0;
}
#id_guard_portal { #id_guard_portal {
display: none; display: none;
position: fixed; position: fixed;

View File

@@ -164,6 +164,17 @@
opacity: 0.6; opacity: 0.6;
&:hover { opacity: 1; } &:hover { opacity: 1; }
&--label {
font-size: 1.1rem;
font-weight: bold;
font-style: italic;
color: rgba(var(--terUser), 1);
opacity: 1;
cursor: default;
border-style: solid;
&:hover { opacity: 1; }
}
} }
// Confirmed palette swatch — right-side thumbnail, same gradient as .note-swatch-body. // Confirmed palette swatch — right-side thumbnail, same gradient as .note-swatch-body.

View File

@@ -39,6 +39,8 @@
{% if item.obj.palette %} {% if item.obj.palette %}
<div class="note-item__palette {{ item.obj.palette }}"></div> <div class="note-item__palette {{ item.obj.palette }}"></div>
{% elif item.swatch_label %}
<div class="note-item__image-box note-item__image-box--label">{{ item.swatch_label }}</div>
{% else %} {% else %}
<div class="note-item__image-box">?</div> <div class="note-item__image-box">?</div>
{% endif %} {% endif %}

View File

@@ -2,7 +2,7 @@
<nav class="navbar"> <nav class="navbar">
<div class="container-fluid"> <div class="container-fluid">
<a href="/" class="navbar-brand"> <a href="/" class="navbar-brand">
<h1>Welcome,<br><span id="id_greeting_name">{% if user.active_title %}{{ user.active_title.slug|capfirst }}{% else %}Earthman{% endif %}</span></h1> <h1>{% if user.active_title %}{{ user.active_title.display_greeting|safe }}{% else %}Welcome,{% endif %}<br><span id="id_greeting_name">{% if user.active_title %}{{ user.active_title.display_title }}{% else %}Earthman{% endif %}</span></h1>
</a> </a>
{% if user.email %} {% if user.email %}
<div class="navbar-user"> <div class="navbar-user">