Compare commits

...

11 Commits

Author SHA1 Message Date
Disco DeDisco
d79380faa5 fix stale test assertions after note-page interaction changes — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- NoteEquipTitleViewTest.test_doff_returns_200: assert greeting + title fields now returned by doff_title view
- NoteEquipTitleTest FT: click note item to lock before reading DON/DOFF text — opacity:0 by default makes .text return empty

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 02:00:22 -04:00
Disco DeDisco
e78bbb873b Billnotes note-page interaction: hover glow, click-lock, DON/DOFF; note titles + card-ref styling — TDD
- note-page.js: click-lock (_lockedItem + notes-locked body class); DON auto-DOFFs prev donned; _setGreeting updates navbar; _donnedItem exposed for test API
- NotePageSpec.js: 18 Jasmine specs covering lock/unlock, DON/DOFF state, auto-DOFF, greeting update, initial load; flushPromises helper for chained fetch .then()
- _note.scss: DON/DOFF opacity:0 by default; hover + locked + donned states show them; body:not(.notes-locked) hover suppression
- views.py: Super-Schizo/Super-Nomad card titles; recognition_title field (display_title) separate from card title; mark_safe descriptions w. card-ref spans
- my_notes.html: |safe on description; recognition_title for Recognitions block
- _navbar.html: id_greeting_prefix/id_greeting_name spans for JS greeting update
- _base.scss: global .card-ref rule (--terUser, font-weight 600, !important)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 01:54:57 -04:00
Disco DeDisco
763d555f0c backfill super-schizo + super-nomad Notes to existing superusers
Migration 0003: grants both Notes to all existing is_superuser=True users.
Covers accounts created before the post_save signal was wired.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 01:31:36 -04:00
Disco DeDisco
6ad736413b 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>
2026-04-28 01:30:02 -04:00
Disco DeDisco
1c2b8f96ab SIG SELECT: Nomad/Schizo locked by default; Note-unlock gate — TDD
- _filter_major_unlocks(cards, user): strips Major 0 (Nomad) and Major 1
  (Schizo) unless user has matching 'nomad'/'schizo' Note; unauthenticated
  users see 0 majors
- levity_sig_cards(room, user) / gravity_sig_cards(room, user): accept user
  param; default 16 court cards, up to 18 with both Note unlocks
- View wires user into both calls; _sig_unique_cards / sig_deck_cards unchanged
  (game-table deck still includes all 18 unique)
- _full_sig_setUp: seats now carry deck_variant=earthman
- SigCardHelperTest: 4 new ITs (default 16, nomad +1, schizo +1); empty-deck
  test updated to clear seats + owner
- SigSelectRenderingTest: 18-card test updated to 16-default + 3 Note-unlock ITs

Pending: superusers auto-granted nomad + schizo Notes on creation (ask user)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 01:05:25 -04:00
Disco DeDisco
eaff2a1edb setup_sig_session: wire deck contributions; _room_deck_variant replaces owner.equipped_deck
- setup_sig_session: drop _ensure_earthman() (deck seeded by migration); set
  deck_variant=earthman on all TableSeats; users get unlocked_decks add but
  equipped_deck=None (seat owns the deck); docstring documents role-pair mapping
- _room_deck_variant(room): new helper looks up deck from any seated deck_variant,
  falls back to owner.equipped_deck for legacy rooms
- sig_deck_cards / _sig_unique_cards: use _room_deck_variant instead of
  owner.equipped_deck — sig cards now work even when users have unequipped their deck
  after role confirmation

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 00:45:22 -04:00
Disco DeDisco
e512e94056 ROLE SELECT: block role pick without deck; SigSelect qualifier spec fix — TDD
- role-select.js: _showNoDeckWarning(stack) intercepts at openFan() — fan never
  opens when data-equipped-deck=""; warning positioned fixed over card-stack via
  getBoundingClientRect(); .guard-actions wrapper for FYI/.btn-caution + NVM/.btn-cancel
- room.html: card-stack gains data-equipped-deck="{{ equipped_deck_id|default:'' }}"
- room view context: equipped_deck_id added
- _room.scss: .role-no-deck-warning — glass guard style matching #id_guard_portal
- _base.scss + _room.scss: guard portal + no-deck warning opacity 0.5 → 0.75
  (matches .tt tooltip; light-palette handled via --tooltip-bg CSS var)
- RoleSelectSpec.js: 8 Jasmine specs — no-deck (fan blocked, warning, FYI/NVM,
  no duplicate, no POST) + deck-present pass-through; afterEach cleans up warning
- SigSelectSpec.js: card fixture gains data-levity-qualifier + data-gravity-qualifier;
  all "Leavened" expectations updated to "Elevated"

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:52:22 -04:00
Disco DeDisco
fa68c74b51 deck contribution sprint 2 + Carte Blanche safeguards — TDD
Sprint 2 UI (game kit applet):
- _applet-game-kit.html: in-use deck → two disabled × buttons, .tt-deck-game-name;
  in-use Carte Blanche → two disabled × buttons, data-current-room-name,
  .tt-token-room-name; tooltip content mirrors kit bag panel (Default, card count,
  description, Stock version)
- gameboard.js buildMiniContent: 'In-Use' for tokens w. data-current-room-name set
- _kit_bag_panel.html: Deck section always renders (placeholder when unequipped)

View safeguards:
- select_role: look up existing deck from prior seat in same room before
  equipped_deck (Carte Blanche multi-seat); only unequip when using equipped_deck
- drop_token Carte: reject 409 if token.current_room is a different room;
  unequip from equipped_trinket on drop

ITs: SelectRoleMultiSeatTest (2), DropTokenViewTest +3 (carte drop, unequip, lock)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 23:24:43 -04:00
Disco DeDisco
94a864b05b deck contribution sprint 1: TableSeat.deck_variant FK + select_role wiring — TDD
- epic.TableSeat gains deck_variant FK → DeckVariant (nullable, SET_NULL)
- select_role view assigns request.user.equipped_deck to seat on role confirmation
- Migration 0006_add_deck_variant_to_tableseat
- ITs: test_select_role_assigns_equipped_deck_to_seat,
       test_select_role_no_deck_leaves_deck_variant_null

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 22:38:07 -04:00
Disco DeDisco
42be0c63dc SIG SELECT: read qualifiers from model fields; drop hardcoded Leavened/Graven
- _sig_select_overlay.html: add data-levity-qualifier + data-gravity-qualifier
  to sig card elements so JS can read per-card values
- sig-select.js: derive qualifier from cardEl.dataset instead of hardcoded string
- _sea_overlay.html: use my_tray_sig.levity_qualifier / gravity_qualifier;
  collapse MIDDLE/MAJOR/else branches → MAJOR vs rest (all non-major show
  qualifier above name; empty qualifier renders empty <p>)
- views.py: SIG READY event display uses card qualifier fields directly;
  removes separate MIDDLE / MAJOR / else branches

Earthman courts now show Elevated/Graven; pips show Relieving/Grieving;
Nomad and Popes show Enlightened/Engraven.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 22:33:42 -04:00
Disco DeDisco
e6e2bd10c5 deck contribution + game invite: write all FTs red — TDD
5-sprint outside-in FT suite. Each FT class drives one sprint:

Sprint 1 (DeckContributionTest): role confirmation sets TableSeat.deck_variant
  to the gamer's equipped deck; Game Kit tooltip shows game name + In-Use status.
Sprint 2 (DeckInUseGameKitTest): DON btn-disabled w.o DOFF toggle for in-use
  deck; tooltip names the game; non-contributing deck retains normal DON/DOFF.
Sprint 3 (GameInviteNotificationTest, @two-browser): invite_gamer() creates
  BillPost(kind=INVITE) for invitee; INVITE: <room> link appears in My Posts.
Sprint 4 (GameInviteBillPostTest): /billboard/post/<pk>/ renders _billpost_invite
  partial; BYE dismisses; OK shows join-guard when valid deck is equipped.
Sprint 5 (GameInviteDeckValidationTest): OK btn-disabled + tooltip when no valid
  deck; confirming join assigns deck to seat and locks Game Kit DON.

New model surface: billboard.BillPost (kind, recipient, room, invite, dismissed)
New field: epic.TableSeat.deck_variant FK → DeckVariant

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 22:22:08 -04:00
39 changed files with 1864 additions and 176 deletions

View File

@@ -5,6 +5,8 @@
var _activeItem = null; var _activeItem = null;
var _originalPalette = null; var _originalPalette = null;
var _dismissTimer = null; var _dismissTimer = null;
var _lockedItem = null; // click-locked note (glow + DON/DOFF pinned)
var _donnedItem = null; // currently DONned note (persistent glow)
// ── helpers ────────────────────────────────────────────────────────────── // ── helpers ──────────────────────────────────────────────────────────────
@@ -47,6 +49,23 @@
if (el) el.style.display = 'none'; if (el) el.style.display = 'none';
} }
// ── lock helpers ──────────────────────────────────────────────────────────
function _clearLock() {
if (_lockedItem) {
_lockedItem.classList.remove('note-item--locked');
_lockedItem = null;
}
document.body.classList.remove('notes-locked');
}
function _setGreeting(greeting, name) {
var prefix = document.getElementById('id_greeting_prefix');
var nameEl = document.getElementById('id_greeting_name');
if (prefix) prefix.innerHTML = greeting;
if (nameEl) nameEl.textContent = name;
}
// ── modal lifecycle ─────────────────────────────────────────────────────── // ── modal lifecycle ───────────────────────────────────────────────────────
function _openModal() { function _openModal() {
@@ -88,47 +107,31 @@
_originalPalette = null; _originalPalette = null;
} }
// Wire event listeners onto the freshly-cloned modal DOM.
function _wireModal() { function _wireModal() {
var modal = _activeModal(); var modal = _activeModal();
if (!modal) return; if (!modal) return;
// Swatch body click → preview palette sitewide + show confirm
modal.querySelectorAll('.note-swatch-body').forEach(function (body) { modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) { body.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
if (_selectedPalette) _revertPreview(); if (_selectedPalette) _revertPreview();
_selectedPalette = _paletteClass(body.parentElement); _selectedPalette = _paletteClass(body.parentElement);
_originalPalette = _currentBodyPalette(); _originalPalette = _currentBodyPalette();
body.classList.add('previewing'); body.classList.add('previewing');
_swapBodyPalette(_selectedPalette); _swapBodyPalette(_selectedPalette);
_showConfirm(modal); _showConfirm(modal);
_dismissTimer = setTimeout(function () { _revertPreview(); }, 10000);
_dismissTimer = setTimeout(function () {
_revertPreview();
}, 10000);
}); });
}); });
// Confirm OK → commit palette sitewide
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) { modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) { e.stopPropagation(); _doSetPalette(); });
e.stopPropagation();
_doSetPalette();
});
}); });
// Confirm NVM → revert preview only; main swatch modal stays open
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) { modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) { e.stopPropagation(); _revertPreview(); });
e.stopPropagation();
_revertPreview();
});
}); });
// Stop modal clicks from reaching the body dismiss handler.
modal.addEventListener('click', function (e) { e.stopPropagation(); }); modal.addEventListener('click', function (e) { e.stopPropagation(); });
} }
@@ -138,18 +141,13 @@
var url = _activeItem.dataset.setPaletteUrl; var url = _activeItem.dataset.setPaletteUrl;
var palette = _selectedPalette; var palette = _selectedPalette;
var item = _activeItem; var item = _activeItem;
// Read label from swatch row while modal is still in DOM
var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]'); var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]');
var paletteLabel = swatchRow var paletteLabel = swatchRow
? swatchRow.dataset.paletteLabel ? swatchRow.dataset.paletteLabel
: palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); }); : palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); });
fetch(url, { fetch(url, {
method: 'POST', method: 'POST', credentials: 'same-origin',
credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': _getCsrf() },
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': _getCsrf(),
},
body: JSON.stringify({ palette: palette }), body: JSON.stringify({ palette: palette }),
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
@@ -171,12 +169,7 @@
}); });
} }
// ── init ────────────────────────────────────────────────────────────────── // ── DON/DOFF ──────────────────────────────────────────────────────────────
function _setGreeting(name) {
var el = document.getElementById('id_greeting_name');
if (el) el.textContent = name;
}
function _bindDonDoff(item) { function _bindDonDoff(item) {
var donBtn = item.querySelector('.note-don-btn'); var donBtn = item.querySelector('.note-don-btn');
@@ -192,11 +185,21 @@
}) })
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { .then(function (data) {
_setGreeting(data.title); // Auto-DOFF any previously DONned note (UI only — backend replaces active_title)
donBtn.classList.add('btn-disabled'); if (_donnedItem && _donnedItem !== item) {
donBtn.textContent = '×'; _donnedItem.classList.remove('note-item--donned');
doffBtn.classList.remove('btn-disabled'); var prevDon = _donnedItem.querySelector('.note-don-btn');
doffBtn.textContent = 'DOFF'; var prevDoff = _donnedItem.querySelector('.note-doff-btn');
if (prevDon) { prevDon.classList.remove('btn-disabled'); prevDon.textContent = 'DON'; }
if (prevDoff) { prevDoff.classList.add('btn-disabled'); prevDoff.textContent = '×'; }
}
_donnedItem = item;
item.classList.add('note-item--donned');
// Clear lock so hover is restored for other notes
_clearLock();
donBtn.classList.add('btn-disabled'); donBtn.textContent = '×';
doffBtn.classList.remove('btn-disabled'); doffBtn.textContent = 'DOFF';
_setGreeting(data.greeting || 'Welcome,', data.title || 'Earthman');
}); });
}); });
@@ -207,33 +210,60 @@
method: 'POST', credentials: 'same-origin', method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() }, headers: { 'X-CSRFToken': _getCsrf() },
}) })
.then(function () { .then(function (r) { return r.json(); })
_setGreeting('Earthman'); .then(function (data) {
doffBtn.classList.add('btn-disabled'); _donnedItem = null;
doffBtn.textContent = '×'; item.classList.remove('note-item--donned');
donBtn.classList.remove('btn-disabled'); _clearLock();
donBtn.textContent = 'DON'; doffBtn.classList.add('btn-disabled'); doffBtn.textContent = '×';
donBtn.classList.remove('btn-disabled'); donBtn.textContent = 'DON';
_setGreeting(data.greeting || 'Welcome,', data.title || 'Earthman');
}); });
}); });
} }
// ── init ──────────────────────────────────────────────────────────────────
function _init() { function _init() {
document.querySelectorAll('.note-item__image-box').forEach(function (box) { document.querySelectorAll('.note-item').forEach(function (item) {
box.addEventListener('click', function (e) { // Detect already-DONned note on load (DON btn is disabled = currently equipped)
var don = item.querySelector('.note-don-btn');
if (don && don.classList.contains('btn-disabled')) {
item.classList.add('note-item--donned');
_donnedItem = item;
}
_bindDonDoff(item);
// Image box click → palette modal (for notes that have one)
var box = item.querySelector('.note-item__image-box:not(.note-item__image-box--label)');
if (box) {
box.addEventListener('click', function (e) {
e.stopPropagation();
_activeItem = item;
_openModal();
});
}
// Note click → toggle lock
item.addEventListener('click', function (e) {
e.stopPropagation(); e.stopPropagation();
_activeItem = box.closest('.note-item'); if (_lockedItem === item) {
_openModal(); _clearLock();
} else {
_clearLock();
_lockedItem = item;
item.classList.add('note-item--locked');
document.body.classList.add('notes-locked');
}
}); });
}); });
document.querySelectorAll('.note-item').forEach(function (item) { // Body click → dismiss modal and clear lock
_bindDonDoff(item);
});
// Body click → dismiss modal and revert any preview
document.body.addEventListener('click', function () { document.body.addEventListener('click', function () {
if (_selectedPalette) _revertPreview(); if (_selectedPalette) _revertPreview();
_closeModal(); _closeModal();
_clearLock();
}); });
} }
@@ -242,4 +272,20 @@
} else { } else {
_init(); _init();
} }
// Expose test API
window.NotePage = {
_init: _init,
_testReset: function () {
_selectedPalette = null;
_activeItem = null;
_originalPalette = null;
_dismissTimer = null;
_lockedItem = null;
_donnedItem = null;
document.body.classList.remove('notes-locked');
},
get _donnedItem() { return _donnedItem; },
set _donnedItem(v) { _donnedItem = v; },
};
}()); }());

View File

@@ -310,7 +310,10 @@ class NoteEquipTitleViewTest(TestCase):
def test_doff_returns_200(self): def test_doff_returns_200(self):
response = self.client.post("/billboard/note/stargazer/doff") response = self.client.post("/billboard/note/stargazer/doff")
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True}) data = response.json()
self.assertTrue(data["ok"])
self.assertEqual(data["greeting"], "Welcome,")
self.assertEqual(data["title"], "Earthman")
def test_don_requires_login(self): def test_don_requires_login(self):
self.client.logout() self.client.logout()

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": "Super-Schizo",
"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": "Super-Nomad",
"description": mark_safe('Admin access granted to <span class="card-ref">0. The Nomad</span> as Significator'),
"palette_options": [],
"swatch_label": "0",
}, },
} }
@@ -140,12 +156,14 @@ def my_notes(request):
active_title = request.user.active_title active_title = request.user.active_title
note_items = [ note_items = [
{ {
"obj": n, "obj": n,
"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", ""), "recognition_title": n.display_title,
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []), "description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "", "palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"is_equipped": active_title is not None and active_title.pk == n.pk, "swatch_label": _NOTE_META.get(n.slug, {}).get("swatch_label"),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
"is_equipped": active_title is not None and active_title.pk == n.pk,
} }
for n in qs for n in qs
] ]
@@ -166,8 +184,7 @@ def don_title(request, slug):
if request.method == "POST": if request.method == "POST":
request.user.active_title = note request.user.active_title = note
request.user.save(update_fields=["active_title"]) request.user.save(update_fields=["active_title"])
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize()) return JsonResponse({"title": note.display_title, "greeting": note.display_greeting})
return JsonResponse({"title": title})
@login_required(login_url="/") @login_required(login_url="/")
@@ -175,7 +192,7 @@ def doff_title(request, slug):
if request.method == "POST": if request.method == "POST":
request.user.active_title = None request.user.active_title = None
request.user.save(update_fields=["active_title"]) request.user.save(update_fields=["active_title"])
return JsonResponse({"ok": True}) return JsonResponse({"ok": True, "greeting": "Welcome,", "title": "Earthman"})
@login_required(login_url="/") @login_required(login_url="/")

View File

@@ -0,0 +1,27 @@
"""Backfill super-schizo and super-nomad Notes for all existing superusers."""
from django.db import migrations
from django.utils import timezone
def grant_super_notes(apps, schema_editor):
User = apps.get_model("lyric", "User")
Note = apps.get_model("drama", "Note")
now = timezone.now()
for user in User.objects.filter(is_superuser=True):
for slug in ("super-schizo", "super-nomad"):
Note.objects.get_or_create(
user=user, slug=slug,
defaults={"earned_at": now},
)
class Migration(migrations.Migration):
dependencies = [
("drama", "0002_initial"),
("lyric", "0001_initial"),
]
operations = [
migrations.RunPython(grant_super_notes, migrations.RunPython.noop),
]

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

@@ -0,0 +1,19 @@
# Generated by Django 6.0 on 2026-04-28 02:35
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0005_seed_astro_reference_tables'),
]
operations = [
migrations.AddField(
model_name='tableseat',
name='deck_variant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_seats', to='epic.deckvariant'),
),
]

View File

@@ -185,6 +185,10 @@ class TableSeat(models.Model):
"TarotCard", null=True, blank=True, "TarotCard", null=True, blank=True,
on_delete=models.SET_NULL, related_name="significator_seats", on_delete=models.SET_NULL, related_name="significator_seats",
) )
deck_variant = models.ForeignKey(
"DeckVariant", null=True, blank=True,
on_delete=models.SET_NULL, related_name="active_seats",
)
class DeckVariant(models.Model): class DeckVariant(models.Model):
@@ -417,6 +421,19 @@ class SigReservation(models.Model):
# ── Significator deck helpers ───────────────────────────────────────────────── # ── Significator deck helpers ─────────────────────────────────────────────────
def _room_deck_variant(room):
"""Return the DeckVariant in use for this room.
Looks up the deck committed to any TableSeat in the room (all seats share the
same deck per game). Falls back to the room owner's equipped_deck for rooms
created before deck contribution was wired.
"""
seat = room.table_seats.filter(deck_variant__isnull=False).first()
if seat:
return seat.deck_variant
return room.owner.equipped_deck
def sig_deck_cards(room): def sig_deck_cards(room):
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2). """Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
@@ -425,7 +442,7 @@ def sig_deck_cards(room):
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
Total: 18 unique × 2 (levity + gravity piles) = 36 cards. Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
""" """
deck_variant = room.owner.equipped_deck deck_variant = _room_deck_variant(room)
if deck_variant is None: if deck_variant is None:
return [] return []
wands_crowns = list(TarotCard.objects.filter( wands_crowns = list(TarotCard.objects.filter(
@@ -451,7 +468,7 @@ def sig_deck_cards(room):
def _sig_unique_cards(room): def _sig_unique_cards(room):
"""Return the 18 unique TarotCard objects that form one sig pile.""" """Return the 18 unique TarotCard objects that form one sig pile."""
deck_variant = room.owner.equipped_deck deck_variant = _room_deck_variant(room)
if deck_variant is None: if deck_variant is None:
return [] return []
wands_crowns = list(TarotCard.objects.filter( wands_crowns = list(TarotCard.objects.filter(
@@ -474,14 +491,27 @@ def _sig_unique_cards(room):
return wands_crowns + swords_cups + major return wands_crowns + swords_cups + major
def levity_sig_cards(room): def _filter_major_unlocks(cards, user):
"""The 18 cards available to the levity group (PC/NC/SC).""" """Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
return _sig_unique_cards(room) if user is None or not user.is_authenticated:
return [c for c in cards if c.arcana != TarotCard.MAJOR]
earned = set(user.notes.values_list("slug", flat=True))
return [
c for c in cards
if c.arcana != TarotCard.MAJOR
or (c.number == 0 and earned & {"nomad", "super-nomad"})
or (c.number == 1 and earned & {"schizo", "super-schizo"})
]
def gravity_sig_cards(room): def levity_sig_cards(room, user=None):
"""The 18 cards available to the gravity group (BC/EC/AC).""" """Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks."""
return _sig_unique_cards(room) return _filter_major_unlocks(_sig_unique_cards(room), user)
def gravity_sig_cards(room, user=None):
"""Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks."""
return _filter_major_unlocks(_sig_unique_cards(room), user)
def sig_seat_order(room): def sig_seat_order(room):

View File

@@ -41,6 +41,29 @@ var RoleSelect = (function () {
if (backdrop) backdrop.remove(); if (backdrop) backdrop.remove();
} }
function _showNoDeckWarning(stack) {
if (document.querySelector(".role-no-deck-warning")) return;
var el = document.createElement("div");
el.className = "role-no-deck-warning";
el.innerHTML =
"<p>Equip card deck before Role select</p>" +
"<div class=\"guard-actions\">" +
"<a class=\"btn btn-caution\" href=\"/gameboard/\">FYI</a>" +
"<button class=\"btn btn-cancel\">NVM</button>" +
"</div>";
el.querySelector(".btn-cancel").addEventListener("click", function () {
el.remove();
});
if (stack) {
var rect = stack.getBoundingClientRect();
el.style.position = "fixed";
el.style.left = Math.round(rect.left + rect.width / 2) + "px";
el.style.top = Math.round(rect.top + rect.height / 2) + "px";
el.style.transform = "translate(-50%, -50%)";
}
document.body.appendChild(el);
}
function selectRole(roleCode) { function selectRole(roleCode) {
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag _turnChangedBeforeFetch = false; // fresh selection, reset the race flag
closeFan(); closeFan();
@@ -138,6 +161,12 @@ var RoleSelect = (function () {
function openFan() { function openFan() {
if (document.querySelector(".role-select-backdrop")) return; if (document.querySelector(".role-select-backdrop")) return;
var stack = document.querySelector(".card-stack[data-starter-roles]");
if (stack && stack.dataset.equippedDeck === "") {
_showNoDeckWarning(stack);
return;
}
var taken = getStarterRoles(); var taken = getStarterRoles();
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; }); var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });

View File

@@ -118,7 +118,9 @@ var SigSelect = (function () {
stageCard.querySelector('.fan-card-arcana').textContent = arcana; stageCard.querySelector('.fan-card-arcana').textContent = arcana;
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven'; var qualifier = userPolarity === 'levity'
? (cardEl.dataset.levityQualifier || '')
: (cardEl.dataset.gravityQualifier || '');
var isMajor = arcana.toLowerCase().indexOf('major') !== -1; var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle. // Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title; stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;

View File

@@ -445,25 +445,44 @@ class SigReservationModelTest(TestCase):
class SigCardHelperTest(TestCase): class SigCardHelperTest(TestCase):
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each. """levity_sig_cards() and gravity_sig_cards() return 16 courts by default;
Nomad/Schizo added when the user has the matching Note unlock.
Relies on the Earthman deck seeded by migrations (no manual card creation). Relies on the Earthman deck seeded by migrations (no manual card creation).
""" """
def setUp(self): def setUp(self):
# Earthman deck is already seeded by migrations from django.utils import timezone
self.earthman = DeckVariant.objects.get(slug="earthman") self.earthman = DeckVariant.objects.get(slug="earthman")
self.owner = User.objects.create(email="founder@test.io") self.owner = User.objects.create(email="founder@test.io")
self.owner.equipped_deck = self.earthman TableSeat.objects.create(
self.owner.save() room=Room.objects.create(name="Card Test", owner=self.owner),
self.room = Room.objects.create(name="Card Test", owner=self.owner) gamer=self.owner, slot_number=1, role="PC",
deck_variant=self.earthman,
)
self.room = self.owner.table_seats.first().room
self._tz = timezone
def test_levity_sig_cards_returns_18(self): def test_levity_sig_cards_returns_16_without_notes(self):
cards = levity_sig_cards(self.room) cards = levity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 18) self.assertEqual(len(cards), 16)
def test_gravity_sig_cards_returns_18(self): def test_gravity_sig_cards_returns_16_without_notes(self):
cards = gravity_sig_cards(self.room) cards = gravity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 18) self.assertEqual(len(cards), 16)
def test_nomad_note_includes_nomad(self):
from apps.drama.models import Note
Note.objects.create(user=self.owner, slug="nomad", earned_at=self._tz.now())
cards = levity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 0 and c.arcana == "MAJOR" for c in cards))
def test_schizo_note_includes_schizo(self):
from apps.drama.models import Note
Note.objects.create(user=self.owner, slug="schizo", earned_at=self._tz.now())
cards = levity_sig_cards(self.room, self.owner)
self.assertEqual(len(cards), 17)
self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))
def test_levity_and_gravity_share_same_card_objects(self): def test_levity_and_gravity_share_same_card_objects(self):
"""Both piles draw from the same 18 TarotCard instances — visual distinction """Both piles draw from the same 18 TarotCard instances — visual distinction
@@ -475,11 +494,13 @@ class SigCardHelperTest(TestCase):
sorted(c.pk for c in gravity), sorted(c.pk for c in gravity),
) )
def test_returns_empty_when_no_equipped_deck(self): def test_returns_empty_when_no_deck_on_seats_or_owner(self):
"""Falls back to empty list when neither seats nor owner have a deck."""
self.room.table_seats.update(deck_variant=None)
self.owner.equipped_deck = None self.owner.equipped_deck = None
self.owner.save() self.owner.save()
self.assertEqual(levity_sig_cards(self.room), []) self.assertEqual(levity_sig_cards(self.room, self.owner), [])
self.assertEqual(gravity_sig_cards(self.room), []) self.assertEqual(gravity_sig_cards(self.room, self.owner), [])
class TarotCardCautionsTest(TestCase): class TarotCardCautionsTest(TestCase):

View File

@@ -5,7 +5,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from apps.drama.models import GameEvent from apps.drama.models import GameEvent, Note
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
from apps.epic.models import ( from apps.epic.models import (
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard, Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
@@ -142,6 +142,39 @@ class DropTokenViewTest(TestCase):
response, reverse("epic:gatekeeper", args=[self.room.id]) response, reverse("epic:gatekeeper", args=[self.room.id])
) )
def test_carte_drop_sets_current_room(self):
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
data={"token_id": carte.pk},
)
carte.refresh_from_db()
self.assertEqual(carte.current_room, self.room)
def test_carte_drop_unequips_trinket(self):
carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
self.gamer.equipped_trinket = carte
self.gamer.save(update_fields=["equipped_trinket"])
self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
data={"token_id": carte.pk},
)
self.gamer.refresh_from_db()
self.assertIsNone(self.gamer.equipped_trinket)
def test_carte_drop_rejected_when_already_in_different_room(self):
other_room = Room.objects.create(name="Other Room", owner=self.gamer)
carte = Token.objects.create(
user=self.gamer, token_type=Token.CARTE, current_room=other_room,
)
response = self.client.post(
reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
data={"token_id": carte.pk},
)
self.assertEqual(response.status_code, 409)
carte.refresh_from_db()
self.assertEqual(carte.current_room, other_room) # unchanged
class ConfirmTokenViewTest(TestCase): class ConfirmTokenViewTest(TestCase):
def setUp(self): def setUp(self):
@@ -680,6 +713,36 @@ class SelectRoleViewTest(TestCase):
) )
mock_notify.assert_called_once_with(self.room.id) mock_notify.assert_called_once_with(self.room.id)
def test_select_role_assigns_equipped_deck_to_seat(self):
earthman = DeckVariant.objects.get(slug="earthman")
self.founder.equipped_deck = earthman
self.founder.save(update_fields=["equipped_deck"])
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
seat = TableSeat.objects.get(room=self.room, slot_number=1)
self.assertEqual(seat.deck_variant, earthman)
def test_select_role_no_deck_leaves_deck_variant_null(self):
self.founder.equipped_deck = None
self.founder.save(update_fields=["equipped_deck"])
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
seat = TableSeat.objects.get(room=self.room, slot_number=1)
self.assertIsNone(seat.deck_variant)
def test_select_role_unequips_deck_from_user(self):
earthman = DeckVariant.objects.get(slug="earthman")
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "PC"},
)
self.founder.refresh_from_db()
self.assertIsNone(self.founder.equipped_deck)
def test_select_role_requires_login(self): def test_select_role_requires_login(self):
self.client.logout() self.client.logout()
response = self.client.post( response = self.client.post(
@@ -743,6 +806,55 @@ class SelectRoleViewTest(TestCase):
) )
class SelectRoleMultiSeatTest(TestCase):
"""Carte Blanche multi-seat: second role reuses the deck from the first seat."""
def setUp(self):
self.founder = User.objects.create(email="founder@test.io")
self.client.force_login(self.founder)
self.room = Room.objects.create(name="Test Room", owner=self.founder)
self.room.gate_status = Room.OPEN
self.room.table_status = Room.ROLE_SELECT
self.room.save()
self.earthman = DeckVariant.objects.get(slug="earthman")
def test_second_role_inherits_deck_from_first_seat_in_room(self):
# Founder's first seat: PC already taken with deck assigned
TableSeat.objects.create(
room=self.room, gamer=self.founder, slot_number=1,
role="PC", deck_variant=self.earthman,
)
# Deck unequipped after first role
self.founder.equipped_deck = None
self.founder.save(update_fields=["equipped_deck"])
# Founder's second seat (Carte Blanche): no role yet
second_seat = TableSeat.objects.create(
room=self.room, gamer=self.founder, slot_number=2,
)
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "BC"},
)
second_seat.refresh_from_db()
self.assertEqual(second_seat.deck_variant, self.earthman)
def test_second_role_does_not_unequip_again(self):
"""No-op unequip when deck was already cleared by the first role."""
TableSeat.objects.create(
room=self.room, gamer=self.founder, slot_number=1,
role="PC", deck_variant=self.earthman,
)
self.founder.equipped_deck = None
self.founder.save(update_fields=["equipped_deck"])
TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
self.client.post(
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
data={"role": "BC"},
)
self.founder.refresh_from_db()
self.assertIsNone(self.founder.equipped_deck) # still None, not broken
class RoomViewAllRolesFilledTest(TestCase): class RoomViewAllRolesFilledTest(TestCase):
"""Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button.""" """Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button."""
def setUp(self): def setUp(self):
@@ -920,7 +1032,8 @@ def _full_sig_setUp(test_case, role_order=None):
slot.status = GateSlot.FILLED slot.status = GateSlot.FILLED
slot.save() slot.save()
TableSeat.objects.create( TableSeat.objects.create(
room=room, gamer=gamer, slot_number=i, role=role, role_revealed=True, room=room, gamer=gamer, slot_number=i, role=role,
role_revealed=True, deck_variant=earthman,
) )
room.gate_status = Room.OPEN room.gate_status = Room.OPEN
room.table_status = Room.SIG_SELECT room.table_status = Room.SIG_SELECT
@@ -943,7 +1056,34 @@ class SigSelectRenderingTest(TestCase):
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertContains(response, "id_sig_deck") self.assertContains(response, "id_sig_deck")
def test_sig_deck_contains_18_sig_cards(self): def test_sig_deck_contains_16_sig_cards_by_default(self):
"""Without Note unlocks the deck shows only 16 court cards (no Nomad/Schizo)."""
response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('data-card-id='), 16)
def test_nomad_note_adds_nomad_to_sig_deck(self):
Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('data-card-id='), 17)
def test_schizo_note_adds_schizo_to_sig_deck(self):
Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
response = self.client.get(self.url)
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):
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())
response = self.client.get(self.url) response = self.client.get(self.url)
self.assertEqual(response.content.decode().count('data-card-id='), 18) self.assertEqual(response.content.decode().count('data-card-id='), 18)

View File

@@ -276,6 +276,7 @@ def _role_select_context(room, user):
_my_role = assigned_seats[0].role if assigned_seats else None _my_role = assigned_seats[0].role if assigned_seats else None
ctx = { ctx = {
"card_stack_state": card_stack_state, "card_stack_state": card_stack_state,
"equipped_deck_id": user.equipped_deck_id if user.is_authenticated else None,
"starter_roles": starter_roles, "starter_roles": starter_roles,
"assigned_seats": assigned_seats, "assigned_seats": assigned_seats,
"my_tray_role": _my_role, "my_tray_role": _my_role,
@@ -337,9 +338,9 @@ def _role_select_context(room, user):
ctx["sig_reservations_json"] = json.dumps(reservations) ctx["sig_reservations_json"] = json.dumps(reservations)
if user_polarity == 'levity': if user_polarity == 'levity':
ctx["sig_cards"] = levity_sig_cards(room) ctx["sig_cards"] = levity_sig_cards(room, user)
elif user_polarity == 'gravity': elif user_polarity == 'gravity':
ctx["sig_cards"] = gravity_sig_cards(room) ctx["sig_cards"] = gravity_sig_cards(room, user)
else: else:
ctx["sig_cards"] = [] ctx["sig_cards"] = []
@@ -405,8 +406,13 @@ def drop_token(request, room_id):
if token.token_type == Token.CARTE: if token.token_type == Token.CARTE:
# CARTE enters the machine without reserving a slot — all slots # CARTE enters the machine without reserving a slot — all slots
# become individually claimable via .drop-token-btn # become individually claimable via .drop-token-btn
if token.current_room_id and token.current_room_id != room.id:
return HttpResponse(status=409)
token.current_room = room token.current_room = room
token.save() token.save()
if request.user.equipped_trinket_id == token.pk:
request.user.equipped_trinket = None
request.user.save(update_fields=["equipped_trinket"])
request.session["kit_token_id"] = str(token.id) request.session["kit_token_id"] = str(token.id)
_notify_gate_update(room_id) _notify_gate_update(room_id)
return redirect("epic:gatekeeper", room_id=room_id) return redirect("epic:gatekeeper", room_id=room_id)
@@ -566,6 +572,7 @@ def select_role(request, room_id):
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES] valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
if not role or role not in valid_roles: if not role or role not in valid_roles:
return redirect("epic:room", room_id=room_id) return redirect("epic:room", room_id=room_id)
existing = None
with transaction.atomic(): with transaction.atomic():
active_seat = room.table_seats.select_for_update().filter( active_seat = room.table_seats.select_for_update().filter(
role__isnull=True role__isnull=True
@@ -575,7 +582,16 @@ def select_role(request, room_id):
if room.table_seats.filter(role=role).exists(): if room.table_seats.filter(role=role).exists():
return HttpResponse(status=409) return HttpResponse(status=409)
active_seat.role = role active_seat.role = role
existing = room.table_seats.filter(
gamer=request.user, deck_variant__isnull=False,
).exclude(pk=active_seat.pk).first()
active_seat.deck_variant = (
existing.deck_variant if existing else request.user.equipped_deck
)
active_seat.save() active_seat.save()
if not existing and request.user.equipped_deck:
request.user.equipped_deck = None
request.user.save(update_fields=["equipped_deck"])
record(room, GameEvent.ROLE_SELECTED, actor=request.user, record(room, GameEvent.ROLE_SELECTED, actor=request.user,
role=role, slot_number=active_seat.slot_number, role=role, slot_number=active_seat.slot_number,
role_display=dict(TableSeat.ROLE_CHOICES).get(role, role)) role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
@@ -756,15 +772,11 @@ def sig_ready(request, room_id):
reservation.ready = True reservation.ready = True
reservation.save(update_fields=["ready"]) reservation.save(update_fields=["ready"])
card = reservation.card card = reservation.card
if card and card.arcana == TarotCard.MIDDLE: if card:
_pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven" _qual = card.levity_qualifier if reservation.polarity == SigReservation.LEVITY else card.gravity_qualifier
_card_display = f"{_pol_prefix} {card.name_title}" _card_display = f"{_qual} {card.name_title}" if _qual else card.name_title
elif card and card.arcana == TarotCard.MAJOR:
_base = card.name_title.removeprefix("The ")
_pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave"
_card_display = f"{_base} {_pol_suffix}"
else: else:
_card_display = card.name_title if card else "a card" _card_display = "a card"
record(room, GameEvent.SIG_READY, actor=request.user, record(room, GameEvent.SIG_READY, actor=request.user,
card_name=_card_display, card_name=_card_display,
corner_rank=card.corner_rank if card else "", corner_rank=card.corner_rank if card else "",

View File

@@ -64,10 +64,20 @@ function initGameKitTooltips() {
const tokenId = token.dataset.tokenId; const tokenId = token.dataset.tokenId;
const equippedId = gameKit.dataset.equippedId || ''; const equippedId = gameKit.dataset.equippedId || '';
const equippedDeckId = gameKit.dataset.equippedDeckId || ''; const equippedDeckId = gameKit.dataset.equippedDeckId || '';
const inUseDeckIds = new Set((gameKit.dataset.inUseDeckIds || '').split(',').filter(Boolean));
if (deckId) { if (deckId) {
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped'; if (inUseDeckIds.has(deckId)) {
miniPortal.textContent = 'In-Use';
} else {
miniPortal.textContent = (equippedDeckId && deckId === equippedDeckId) ? 'Equipped' : 'Not Equipped';
}
} else if (tokenId) { } else if (tokenId) {
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped'; const currentRoomName = token.dataset.currentRoomName || '';
if (currentRoomName) {
miniPortal.textContent = 'In-Use';
} else {
miniPortal.textContent = (equippedId && tokenId === equippedId) ? 'Equipped' : 'Not Equipped';
}
} }
} }

View File

@@ -4,7 +4,7 @@ from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from apps.applets.models import Applet, UserApplet from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant from apps.epic.models import DeckVariant, Room, TableSeat
from apps.lyric.models import Token, User from apps.lyric.models import Token, User
@@ -61,6 +61,45 @@ class GameboardViewTest(TestCase):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set") [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
class GameboardDeckInUseTest(TestCase):
"""Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
self.earthman = DeckVariant.objects.get(slug="earthman")
self.room = Room.objects.create(name="Wildfire", owner=self.user)
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.user, slot_number=1,
deck_variant=self.earthman,
)
response = self.client.get("/gameboard/")
self.parsed = lxml.html.fromstring(response.content)
def test_in_use_deck_don_is_disabled(self):
[don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
self.assertIn("btn-disabled", don.get("class", ""))
def test_in_use_deck_doff_is_absent(self):
active_doff = self.parsed.cssselect(
"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
)
self.assertEqual(len(active_doff), 0)
def test_in_use_deck_tooltip_shows_game_name(self):
[label] = self.parsed.cssselect("#id_kit_earthman_deck .tt-deck-game-name")
self.assertIn("Wildfire", label.text_content())
def test_non_in_use_deck_has_normal_don(self):
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
self.user.unlocked_decks.add(fiorentine)
response = self.client.get("/gameboard/")
parsed = lxml.html.fromstring(response.content)
[don] = parsed.cssselect("#id_kit_fiorentine_deck .btn-equip")
self.assertNotIn("btn-disabled", don.get("class", ""))
class ToggleGameAppletsViewTest(TestCase): class ToggleGameAppletsViewTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="gamer@test.io") self.user = User.objects.create(email="gamer@test.io")

View File

@@ -4,7 +4,20 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
from apps.applets.utils import applet_context, apply_applet_toggle from apps.applets.utils import applet_context, apply_applet_toggle
from apps.epic.models import DeckVariant, Room
def _annotate_deck_in_use(decks, user):
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
active = {
ts.deck_variant_id: ts.room.name
for ts in TableSeat.objects.filter(
gamer=user, deck_variant__isnull=False,
).select_related("room")
}
for deck in decks:
deck.in_use_room_name = active.get(deck.pk)
return decks
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.epic.utils import rooms_for_user from apps.epic.utils import rooms_for_user
from apps.lyric.models import Token from apps.lyric.models import Token
@@ -31,7 +44,7 @@ def gameboard(request):
"carte": carte, "carte": carte,
"equipped_trinket_id": request.user.equipped_trinket_id, "equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": request.user.equipped_deck_id, "equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": list(request.user.unlocked_decks.all()), "deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
"free_tokens": free_tokens, "free_tokens": free_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"), "applets": applet_context(request.user, "gameboard"),
@@ -55,7 +68,7 @@ def toggle_game_applets(request):
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": request.user.equipped_trinket_id, "equipped_trinket_id": request.user.equipped_trinket_id,
"equipped_deck_id": request.user.equipped_deck_id, "equipped_deck_id": request.user.equipped_deck_id,
"deck_variants": list(request.user.unlocked_decks.all()), "deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
"free_tokens": free_tokens, "free_tokens": free_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
"my_games": rooms_for_user(request.user), "my_games": rooms_for_user(request.user),

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

@@ -2,8 +2,13 @@
Management command for manual multi-user sig-select testing. Management command for manual multi-user sig-select testing.
Creates (or reuses) a room with all 6 gate slots filled, roles assigned, Creates (or reuses) a room with all 6 gate slots filled, roles assigned,
and table_status=SIG_SELECT. Prints one pre-auth URL per gamer so you can deck contributions wired, and table_status=SIG_SELECT. Prints one pre-auth
paste them into 6 Firefox Multi-Account Container tabs. URL per gamer so you can paste them into 6 Firefox Multi-Account Container tabs.
Deck contribution by role pair (same segment, levity vs gravity pole):
PC & BC → Brands + Crowns (levity / gravity)
SC & AC → Blades + Grails (levity / gravity)
NC & EC → Trumps (levity / gravity)
Usage: Usage:
python src/manage.py setup_sig_session python src/manage.py setup_sig_session
@@ -15,7 +20,8 @@ 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.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard from apps.drama.models import Note
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
from apps.lyric.models import User from apps.lyric.models import User
@@ -31,28 +37,6 @@ GAMERS = [
ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"] ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"]
def _ensure_earthman():
"""Return (or create) the Earthman DeckVariant with enough sig-deck cards seeded."""
earthman, _ = DeckVariant.objects.get_or_create(
slug="earthman",
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
)
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
for number in (11, 12, 13, 14):
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
defaults={
"arcana": "MINOR",
"suit": suit,
"number": number,
"name": f"{_NAME[number]} of {suit.capitalize()}",
},
)
return earthman
def _make_session(user): def _make_session(user):
session = SessionStore() session = SessionStore()
session[SESSION_KEY] = str(user.pk) session[SESSION_KEY] = str(user.pk)
@@ -71,7 +55,7 @@ class Command(BaseCommand):
def handle(self, *args, **options): def handle(self, *args, **options):
base_url = options["base_url"].rstrip("/") base_url = options["base_url"].rstrip("/")
earthman = _ensure_earthman() earthman = DeckVariant.objects.get(slug="earthman")
# ── Users ──────────────────────────────────────────────────────────── # ── Users ────────────────────────────────────────────────────────────
users = [] users = []
@@ -79,9 +63,13 @@ class Command(BaseCommand):
user, _ = User.objects.get_or_create(email=email) user, _ = User.objects.get_or_create(email=email)
user.is_staff = True user.is_staff = True
user.is_superuser = True user.is_superuser = True
if not user.equipped_deck: # Deck will be assigned to seat below; ensure it's in unlocked_decks
user.equipped_deck = earthman # but leave equipped_deck=None (seat assignment owns it)
user.equipped_deck = None
user.save() user.save()
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 ─────────────────────────────────────────────────────────────
@@ -104,11 +92,21 @@ class Command(BaseCommand):
room.gate_status = Room.OPEN room.gate_status = Room.OPEN
room.save() room.save()
# ── Table seats + roles ────────────────────────────────────────────── # ── Table seats + roles + deck contributions ─────────────────────────
# PC/NC/SC → levity pole; BC/EC/AC → gravity pole.
# Each role pair shares a deck segment:
# PC & BC → Brands + Crowns
# SC & AC → Blades + Grails
# NC & EC → Trumps
for i, (user, role) in enumerate(zip(users, ROLES), start=1): for i, (user, role) in enumerate(zip(users, ROLES), start=1):
TableSeat.objects.update_or_create( TableSeat.objects.update_or_create(
room=room, slot_number=i, room=room, slot_number=i,
defaults={"gamer": user, "role": role, "role_revealed": True}, defaults={
"gamer": user,
"role": role,
"role_revealed": True,
"deck_variant": earthman,
},
) )
room.table_status = Room.SIG_SELECT room.table_status = Room.SIG_SELECT

View File

@@ -431,6 +431,8 @@ class NoteEquipTitleTest(FunctionalTest):
note_item = self.wait_for( note_item = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item") lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-item")
) )
# Click the note to lock it — makes DON/DOFF opacity:1 and interactable
note_item.click()
don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip") don_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-equip")
doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip") doff_btn = note_item.find_element(By.CSS_SELECTOR, ".btn-unequip")

View File

@@ -0,0 +1,216 @@
"""
Functional tests: deck-to-seat contribution mechanics.
Sprint 1 (DeckContributionTest):
Confirming a role in ROLE SELECT assigns the gamer's equipped deck to the
TableSeat. The Game Kit immediately reflects the in-use state: the deck
card gains the game name in its tooltip and the micro-status reads "In-Use".
Sprint 2 (DeckInUseGameKitTest):
With a deck already assigned to an active seat the Game Kit DON button is
btn-disabled and no DOFF toggle is shown (unlike the normal equipped state
where DOFF is visible). Clicking the card shows a tooltip naming the game.
A second deck that is NOT assigned to any active seat has normal DON/DOFF
behaviour.
"""
import time
from django.test import tag
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat
from apps.lyric.models import User
from functional_tests.base import FunctionalTest
from functional_tests.test_room_role_select import _fill_room_via_orm
from functional_tests.test_room_sig_select import _assign_all_roles
FOUNDER_EMAIL = "founder@test.io"
GAMER_EMAIL = "gamer@test.io"
def _equip_earthman(user):
earthman = DeckVariant.objects.get(slug="earthman")
user.equipped_deck = earthman
user.save(update_fields=["equipped_deck"])
return earthman
def _room_at_role_select(name="Wildfire"):
"""Create a room filled to 6 and at ROLE_SELECT table status."""
founder, _ = User.objects.get_or_create(email=FOUNDER_EMAIL)
room = Room.objects.create(name=name, owner=founder)
emails = [FOUNDER_EMAIL, GAMER_EMAIL,
"b@test.io", "c@test.io", "d@test.io", "e@test.io"]
_fill_room_via_orm(room, emails)
for slot in room.gate_slots.all():
if slot.gamer:
_equip_earthman(slot.gamer)
room.table_status = Room.ROLE_SELECT
room.save()
return room, founder
# ── Sprint 1 ─────────────────────────────────────────────────────────────────
class DeckContributionTest(FunctionalTest):
"""Sprint 1: role confirmation → TableSeat.deck_variant set; Game Kit shows In-Use."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
for slug, name, ctx in [
("game-kit", "Game Kit", "gameboard"),
("gk-decks", "Card Decks","game-kit"),
]:
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
def test_confirming_role_assigns_equipped_deck_to_seat(self):
"""After the gamer confirms a role, Game Kit shows the Earthman deck as 'In-Use'
with the game name in the deck tooltip, and the micro-status changes from
'Equipped' to 'In-Use'.
"""
room, founder = _room_at_role_select()
gamer = User.objects.get(email=GAMER_EMAIL)
# Gamer logs in and navigates to the role-select room
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
# Gamer confirms a role (NC — slot 2 is theirs)
role_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".role-card[data-role='NC'] .btn-confirm")
)
role_btn.click()
# Deck is now assigned to the seat in the DB
self.wait_for(lambda: self.assertTrue(
TableSeat.objects.filter(
gamer=gamer,
room=room,
deck_variant=gamer.equipped_deck,
).exists(),
"TableSeat.deck_variant was not set after role confirmation",
))
# Navigate to Game Kit → Card Decks to verify UI state
self.browser.get(self.live_server_url + "/gameboard/")
decks_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
)
decks_btn.click()
earthman_card = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
)
earthman_card.click() # open tooltip
# Tooltip shows the game name
tooltip = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".tt-deck-game-name")
)
self.assertIn(room.name.upper(), tooltip.text.upper())
# Micro-status reads "In-Use", not "Equipped"
micro_status = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_earthman_deck .deck-micro-status"
)
)
self.assertIn("IN-USE", micro_status.text.upper())
self.assertNotIn("EQUIPPED", micro_status.text.upper())
# ── Sprint 2 ─────────────────────────────────────────────────────────────────
class DeckInUseGameKitTest(FunctionalTest):
"""Sprint 2: DON disabled + no DOFF toggle for in-use deck; second deck unaffected."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
for slug, name, ctx in [
("game-kit", "Game Kit", "gameboard"),
("gk-decks", "Card Decks","game-kit"),
]:
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
def _setup_in_use_deck(self):
"""Create a seated gamer whose Earthman deck is already assigned to a seat."""
room, founder = _room_at_role_select()
gamer = User.objects.get(email=GAMER_EMAIL)
earthman = _equip_earthman(gamer)
# Assign deck directly (Sprint 1 must be green first)
seat = TableSeat.objects.create(gamer=gamer, room=room, role="NC", deck_variant=earthman)
return gamer, earthman, room, seat
def test_don_is_disabled_and_doff_absent_for_in_use_deck(self):
"""DON button carries btn-disabled; DOFF is not rendered at all (not just disabled)."""
gamer, earthman, room, seat = self._setup_in_use_deck()
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
).click()
# DON is disabled
don_btn = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, f"#id_kit_earthman_deck .btn-equip"
)
)
self.assertIn("btn-disabled", don_btn.get_attribute("class"))
# DOFF button is not present (not just disabled — entirely absent)
doff_btns = self.browser.find_elements(
By.CSS_SELECTOR, f"#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
)
self.assertEqual(len(doff_btns), 0, "DOFF button should not be shown for an in-use deck")
def test_tooltip_names_the_game_for_in_use_deck(self):
"""Opening an in-use deck's tooltip shows the room name it is contributing to."""
gamer, earthman, room, seat = self._setup_in_use_deck()
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
).click()
game_label = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".tt-deck-game-name")
)
self.assertIn(room.name.upper(), game_label.text.upper())
def test_non_contributing_deck_has_normal_don_doff(self):
"""A deck not assigned to any active seat shows the normal DON/DOFF apparatus."""
gamer, earthman, room, seat = self._setup_in_use_deck()
# Unlock Fiorentine for the gamer so it appears in Game Kit
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
gamer.unlocked_decks.add(fiorentine)
session_key = self.create_pre_authenticated_session(GAMER_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + "/gameboard/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_fiorentine_deck")
).click()
don_btn = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_fiorentine_deck .btn-equip"
)
)
self.assertNotIn("btn-disabled", don_btn.get_attribute("class"))

View File

@@ -0,0 +1,354 @@
"""
Functional tests: game-room invite flow via BillPost.
BillPost is a new billboard.BillPost model (kind=INVITE for this sprint;
more kinds to follow in later feature sprints). It is the first of a series
of Post-types that will grow through coming sprints. The existing
dashboard.Post model will be renamed (to Message or similar) when the WS
messaging sprint lands; this new system's naming takes precedence.
Sprint 3 (GameInviteNotificationTest) — @two-browser:
Founder sends an email invite from the room page. invite_gamer() creates a
BillPost(kind=INVITE) for the invitee. Invitee sees "INVITE: <room>" as a
link in the My Posts applet on the billboard page.
Sprint 4 (GameInviteBillPostTest):
Clicking the invite post navigates to /billboard/post/<pk>/. The page
renders a _billpost_invite.html partial with room info. OK and BYE
(.btn-confirm / .btn-abandon) appear next to the title.
BYE marks the BillPost dismissed; the post is no longer listed in My Posts.
OK shows a Log-Out-style confirm guard ("Join game? OK / NVM") if the
invitee has a valid (non-contributing) deck equipped.
Sprint 5 (GameInviteDeckValidationTest):
If the invitee has no deck equipped, or their equipped deck is already
assigned to an active seat, the OK button is immediately btn-disabled
(no guard shown) and a tooltip explains the situation.
When a valid deck is equipped and the invitee confirms, they are redirected
to the room and their deck is assigned to a seat.
"""
import os
import time
from django.test import tag
from selenium import webdriver
from selenium.webdriver.common.by import By
from apps.applets.models import Applet
from apps.billboard.models import BillPost
from apps.epic.models import DeckVariant, GateSlot, Room, RoomInvite, TableSeat
from apps.lyric.models import User
from functional_tests.base import FunctionalTest
from functional_tests.test_room_role_select import _fill_room_via_orm
FOUNDER_EMAIL = "founder@test.io"
INVITEE_EMAIL = "invitee@test.io"
def _applets():
for slug, name, ctx in [
("my-posts", "My Posts", "billboard"),
("billboard-my-scrolls", "My Scrolls","billboard"),
]:
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
def _make_open_room(name="Wildfire"):
founder, _ = User.objects.get_or_create(email=FOUNDER_EMAIL)
room = Room.objects.create(name=name, owner=founder)
_fill_room_via_orm(room, [FOUNDER_EMAIL,
"b@test.io", "c@test.io", "d@test.io", "e@test.io", "f@test.io"])
return room, founder
def _make_second_browser():
opts = webdriver.FirefoxOptions()
if os.environ.get("HEADLESS"):
opts.add_argument("--headless")
b = webdriver.Firefox(options=opts)
b.set_window_size(800, 1200)
return b
# ── Sprint 3 ─────────────────────────────────────────────────────────────────
@tag("two-browser")
class GameInviteNotificationTest(FunctionalTest):
"""Sprint 3: founder sends invite → INVITE: BillPost appears in invitee's My Posts."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
self.second_browser = _make_second_browser()
_applets()
User.objects.get_or_create(email=INVITEE_EMAIL)
def tearDown(self):
self.second_browser.quit()
super().tearDown()
def test_invite_creates_bill_post_in_my_posts(self):
"""Founder sends email invite; invitee sees 'INVITE: Wildfire' link in My Posts."""
room, founder = _make_open_room()
# Founder: log in, navigate to room, send invite
founder_session = self.create_pre_authenticated_session(FOUNDER_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": founder_session})
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
invite_input = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name='invitee_email']")
)
invite_input.send_keys(INVITEE_EMAIL)
self.browser.find_element(By.CSS_SELECTOR, "#id_invite_btn").click()
# A BillPost(kind=INVITE) is created for the invitee
self.wait_for(lambda: self.assertTrue(
BillPost.objects.filter(
kind=BillPost.INVITE,
recipient__email=INVITEE_EMAIL,
room=room,
dismissed=False,
).exists(),
"BillPost(kind=INVITE) was not created for the invitee",
))
# Invitee: log in to billboard, see "INVITE: Wildfire" in My Posts applet
invitee = User.objects.get(email=INVITEE_EMAIL)
invitee_session = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.second_browser.get(self.live_server_url)
self.second_browser.add_cookie({"name": "sessionid", "value": invitee_session})
self.second_browser.get(self.live_server_url + "/billboard/")
my_posts = self.wait_for(
lambda: self.second_browser.find_element(By.CSS_SELECTOR, "#id_applet_my_posts")
)
invite_link = self.wait_for(
lambda: my_posts.find_element(By.PARTIAL_LINK_TEXT, "INVITE:")
)
self.assertIn("WILDFIRE", invite_link.text.upper())
# Link goes to /billboard/post/<pk>/
post = BillPost.objects.get(kind=BillPost.INVITE, recipient__email=INVITEE_EMAIL)
self.assertIn(f"/billboard/post/{post.pk}/", invite_link.get_attribute("href"))
# ── Sprint 4 ─────────────────────────────────────────────────────────────────
class GameInviteBillPostTest(FunctionalTest):
"""Sprint 4: BillPost invite page — _billpost_invite.html partial, OK/BYE, BYE dismisses."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
_applets()
def _make_invite_post(self, equip_deck=False):
room, founder = _make_open_room()
invitee, _ = User.objects.get_or_create(email=INVITEE_EMAIL)
invite = RoomInvite.objects.create(
room=room, inviter=founder, invitee_email=INVITEE_EMAIL,
status=RoomInvite.PENDING,
)
post = BillPost.objects.create(
kind=BillPost.INVITE, recipient=invitee, room=room, invite=invite,
)
if equip_deck:
earthman = DeckVariant.objects.get(slug="earthman")
invitee.equipped_deck = earthman
invitee.unlocked_decks.add(earthman)
invitee.save(update_fields=["equipped_deck"])
return invitee, post, room
def test_billpost_page_shows_invite_partial(self):
"""The BillPost page at /billboard/post/<pk>/ renders the invite partial with room info."""
invitee, post, room = self._make_invite_post()
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
# Page title / heading shows "INVITE: Wildfire"
heading = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billpost-title")
)
self.assertIn("INVITE", heading.text.upper())
self.assertIn(room.name.upper(), heading.text.upper())
# Invite partial is present
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".billpost-invite")
)
# OK and BYE buttons are present
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm"))
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon"))
def test_bye_dismisses_invite_and_removes_from_my_posts(self):
"""Clicking BYE marks the BillPost dismissed and it no longer appears in My Posts."""
invitee, post, room = self._make_invite_post()
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
bye_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
)
bye_btn.click()
# BillPost is now dismissed in DB
post.refresh_from_db()
self.assertTrue(post.dismissed)
# Redirected to billboard; post no longer in My Posts
self.wait_for(lambda: self.assertIn("/billboard/", self.browser.current_url))
my_posts = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_posts")
)
invite_links = my_posts.find_elements(By.PARTIAL_LINK_TEXT, "INVITE:")
self.assertEqual(len(invite_links), 0, "Dismissed invite should not appear in My Posts")
def test_ok_with_valid_deck_shows_confirm_guard(self):
"""With a valid deck equipped, OK shows a Log-Out-style confirm guard."""
invitee, post, room = self._make_invite_post(equip_deck=True)
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
ok_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
)
self.assertNotIn("btn-disabled", ok_btn.get_attribute("class"))
ok_btn.click()
# Confirm guard appears ("Join game?" with OK / NVM)
guard = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".join-guard")
)
self.assertIn("JOIN", guard.text.upper())
self.wait_for(lambda: guard.find_element(By.CSS_SELECTOR, ".btn-confirm"))
self.wait_for(lambda: guard.find_element(By.CSS_SELECTOR, ".btn-nvm"))
# ── Sprint 5 ─────────────────────────────────────────────────────────────────
class GameInviteDeckValidationTest(FunctionalTest):
"""Sprint 5: OK deck validation — disabled without valid deck; on join, deck locked."""
def setUp(self):
super().setUp()
self.browser.set_window_size(800, 1200)
_applets()
for slug, name, ctx in [
("game-kit", "Game Kit", "gameboard"),
("gk-decks", "Card Decks","game-kit"),
]:
Applet.objects.get_or_create(slug=slug, defaults={"name": name, "context": ctx})
def _make_invite_post(self, equip_deck=False, deck_in_use=False):
room, founder = _make_open_room()
invitee, _ = User.objects.get_or_create(email=INVITEE_EMAIL)
invite = RoomInvite.objects.create(
room=room, inviter=founder, invitee_email=INVITEE_EMAIL,
status=RoomInvite.PENDING,
)
post = BillPost.objects.create(
kind=BillPost.INVITE, recipient=invitee, room=room, invite=invite,
)
earthman = DeckVariant.objects.get(slug="earthman")
if equip_deck or deck_in_use:
invitee.equipped_deck = earthman
invitee.unlocked_decks.add(earthman)
invitee.save(update_fields=["equipped_deck"])
if deck_in_use:
# Simulate the deck already being assigned to another active seat
other_room = Room.objects.create(name="Other Game", owner=invitee)
TableSeat.objects.create(
gamer=invitee, room=other_room, role="PC", deck_variant=earthman,
)
return invitee, post, room, earthman
def test_ok_immediately_disabled_without_equipped_deck(self):
"""OK is btn-disabled on load when invitee has no deck equipped."""
invitee, post, room, _ = self._make_invite_post(equip_deck=False)
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
ok_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
)
self.assertIn("btn-disabled", ok_btn.get_attribute("class"),
"OK should be disabled when invitee has no deck equipped")
# Tooltip explains the situation
ok_btn.click()
tooltip = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".invite-no-deck-tooltip")
)
self.assertTrue(tooltip.is_displayed())
def test_ok_immediately_disabled_when_deck_in_use(self):
"""OK is btn-disabled when invitee's equipped deck is already assigned to another game."""
invitee, post, room, _ = self._make_invite_post(deck_in_use=True)
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
ok_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
)
self.assertIn("btn-disabled", ok_btn.get_attribute("class"),
"OK should be disabled when invitee's deck is already in use")
def test_confirming_join_assigns_deck_to_seat_and_locks_game_kit(self):
"""After confirming join, invitee's deck is assigned to their new seat and
the Game Kit DON button for that deck becomes btn-disabled."""
invitee, post, room, earthman = self._make_invite_post(equip_deck=True)
session_key = self.create_pre_authenticated_session(INVITEE_EMAIL)
self.browser.get(self.live_server_url)
self.browser.add_cookie({"name": "sessionid", "value": session_key})
self.browser.get(self.live_server_url + f"/billboard/post/{post.pk}/")
# OK → guard → confirm
ok_btn = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-confirm")
)
ok_btn.click()
guard_ok = self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, ".join-guard .btn-confirm")
)
guard_ok.click()
# Redirected into the room
self.wait_for(
lambda: self.assertIn(f"/gameboard/room/{room.pk}/", self.browser.current_url)
)
# Invitee's seat has their deck assigned
self.wait_for(lambda: self.assertTrue(
TableSeat.objects.filter(
gamer=invitee, room=room, deck_variant=earthman,
).exists(),
"TableSeat.deck_variant was not set on join",
))
# Game Kit reflects in-use state
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_card_deck")
).click()
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_earthman_deck")
).click()
don_btn = self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR, "#id_kit_earthman_deck .btn-equip"
)
)
self.assertIn("btn-disabled", don_btn.get_attribute("class"))

View File

@@ -0,0 +1,204 @@
function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0));
}
describe("NotePage", () => {
let testDiv, item1, item2, don1, doff1, don2, doff2;
function makeItem(slug, isEquipped) {
const li = document.createElement("li");
li.className = "note-item";
li.dataset.slug = slug;
li.dataset.donUrl = `/billboard/note/${slug}/don`;
li.dataset.doffUrl = `/billboard/note/${slug}/doff`;
li.innerHTML = `
<div class="note-don-doff">
<button class="btn btn-equip note-don-btn${isEquipped ? " btn-disabled" : ""}">${isEquipped ? "×" : "DON"}</button>
<button class="btn btn-unequip note-doff-btn${isEquipped ? "" : " btn-disabled"}">${isEquipped ? "DOFF" : "×"}</button>
</div>
<div class="note-item__body"><p class="note-item__title">${slug}</p></div>
`;
return li;
}
beforeEach(() => {
NotePage._testReset();
testDiv = document.createElement("div");
testDiv.innerHTML = `<span id="id_greeting_prefix">Welcome,</span><span id="id_greeting_name">Earthman</span>`;
item1 = makeItem("super-schizo", false);
item2 = makeItem("super-nomad", false);
testDiv.appendChild(item1);
testDiv.appendChild(item2);
document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ title: "Schizoid Man", greeting: "21st Century" }) })
);
don1 = item1.querySelector(".note-don-btn");
doff1 = item1.querySelector(".note-doff-btn");
don2 = item2.querySelector(".note-don-btn");
doff2 = item2.querySelector(".note-doff-btn");
NotePage._init();
});
afterEach(() => {
testDiv.remove();
document.body.classList.remove("notes-locked");
delete window.fetch;
});
// ── Lock / unlock ─────────────────────────────────────────────────────────
describe("click-lock behaviour", () => {
it("clicking a note adds note-item--locked", () => {
item1.click();
expect(item1.classList.contains("note-item--locked")).toBe(true);
});
it("clicking a note adds notes-locked to body", () => {
item1.click();
expect(document.body.classList.contains("notes-locked")).toBe(true);
});
it("clicking the same note again removes lock", () => {
item1.click();
item1.click();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(document.body.classList.contains("notes-locked")).toBe(false);
});
it("clicking a different note moves lock to that note", () => {
item1.click();
item2.click();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(item2.classList.contains("note-item--locked")).toBe(true);
});
it("body click clears all locks", () => {
item1.click();
document.body.click();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(document.body.classList.contains("notes-locked")).toBe(false);
});
});
// ── DON/DOFF state ────────────────────────────────────────────────────────
describe("DON button", () => {
it("clicking DON sends POST to don URL", async () => {
don1.click();
await flushPromises();
expect(window.fetch).toHaveBeenCalledWith(
"/billboard/note/super-schizo/don",
jasmine.objectContaining({ method: "POST" })
);
});
it("after DON, note gains note-item--donned", async () => {
don1.click();
await flushPromises();
expect(item1.classList.contains("note-item--donned")).toBe(true);
});
it("after DON, lock is cleared", async () => {
item1.click(); // lock first
don1.click();
await flushPromises();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(document.body.classList.contains("notes-locked")).toBe(false);
});
it("after DON, greeting prefix and name are updated", async () => {
don1.click();
await flushPromises();
expect(document.getElementById("id_greeting_prefix").innerHTML).toContain("21st Century");
expect(document.getElementById("id_greeting_name").textContent).toBe("Schizoid Man");
});
it("DONning item1 auto-removes donned state from previously DONned item2", async () => {
item2.classList.add("note-item--donned");
don2.classList.add("btn-disabled"); don2.textContent = "×";
doff2.classList.remove("btn-disabled"); doff2.textContent = "DOFF";
NotePage._donnedItem = item2;
don1.click();
await flushPromises();
expect(item2.classList.contains("note-item--donned")).toBe(false);
expect(don2.classList.contains("btn-disabled")).toBe(false);
expect(don2.textContent).toBe("DON");
expect(doff2.classList.contains("btn-disabled")).toBe(true);
});
it("DONning one does not POST a doff for the previous", async () => {
item2.classList.add("note-item--donned");
NotePage._donnedItem = item2;
don1.click();
await flushPromises();
// Only one fetch call (the DON), no DOFF call for item2
expect(window.fetch.calls.count()).toBe(1);
});
});
describe("DOFF button", () => {
it("clicking DOFF sends POST to doff URL", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
);
item1.classList.add("note-item--donned");
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
don1.classList.add("btn-disabled"); don1.textContent = "×";
NotePage._donnedItem = item1;
doff1.click();
await flushPromises();
expect(window.fetch).toHaveBeenCalledWith(
"/billboard/note/super-schizo/doff",
jasmine.objectContaining({ method: "POST" })
);
});
it("after DOFF, note loses note-item--donned", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
);
item1.classList.add("note-item--donned");
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
NotePage._donnedItem = item1;
doff1.click();
await flushPromises();
expect(item1.classList.contains("note-item--donned")).toBe(false);
});
it("after DOFF, greeting reverts to Welcome, / Earthman", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
);
document.getElementById("id_greeting_prefix").textContent = "21st Century";
document.getElementById("id_greeting_name").textContent = "Schizoid Man";
item1.classList.add("note-item--donned");
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
NotePage._donnedItem = item1;
doff1.click();
await flushPromises();
expect(document.getElementById("id_greeting_prefix").textContent).toBe("Welcome,");
expect(document.getElementById("id_greeting_name").textContent).toBe("Earthman");
});
});
// ── Initial donned state ──────────────────────────────────────────────────
describe("initial load with an already-donned note", () => {
it("note whose DON is btn-disabled gets note-item--donned on init", () => {
const equippedItem = makeItem("stargazer", true);
testDiv.appendChild(equippedItem);
NotePage._testReset();
NotePage._init();
expect(equippedItem.classList.contains("note-item--donned")).toBe(true);
});
});
});

View File

@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
afterEach(() => { afterEach(() => {
RoleSelect.closeFan(); RoleSelect.closeFan();
testDiv.remove(); testDiv.remove();
const warning = document.querySelector(".role-no-deck-warning");
if (warning) warning.remove();
delete window.showGuard; delete window.showGuard;
}); });
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
}); });
}); });
}); });
// ------------------------------------------------------------------ //
// No-deck guard //
// data-equipped-deck="" → openFan() blocked; warning shown over stack //
// ------------------------------------------------------------------ //
describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
let stack;
beforeEach(() => {
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
stack.dataset.equippedDeck = ""; // explicitly empty = no deck
testDiv.appendChild(stack);
RoleSelect.openFan(); // intercepted — fan must NOT open
});
it("does not open the fan backdrop", () => {
expect(document.querySelector(".role-select-backdrop")).toBeNull();
});
it("shows .role-no-deck-warning in the DOM", () => {
expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
});
it("warning contains the deck prompt text", () => {
const w = document.querySelector(".role-no-deck-warning");
expect(w.textContent).toContain("Equip card deck before Role select");
});
it("warning has a .btn-caution FYI link to gameboard", () => {
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
expect(btn).not.toBeNull();
expect(btn.tagName).toBe("A");
expect(btn.href).toContain("/gameboard/");
});
it("warning has a .btn-cancel NVM button", () => {
expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
});
it("NVM click removes the warning", () => {
document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
});
it("second openFan() call does not duplicate the warning", () => {
RoleSelect.openFan();
expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
});
it("does not POST to select_role", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
});
describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
beforeEach(() => {
const stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
stack.dataset.equippedDeck = "42"; // non-empty = deck present
testDiv.appendChild(stack);
RoleSelect.openFan();
});
it("opens the fan backdrop normally", () => {
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
});
it("does not show .role-no-deck-warning", () => {
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
});
});
}); });

View File

@@ -58,7 +58,9 @@ describe("SigSelect", () => {
data-correspondence="" data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition" data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences" data-keywords-reversed="no direction,disregard for consequences"
data-cautions="${cardCautions.replace(/"/g, '&quot;')}"> data-cautions="${cardCautions.replace(/"/g, '&quot;')}"
data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven">
<div class="fan-card-corner fan-card-corner--tl"> <div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">K</span> <span class="fan-corner-rank">K</span>
</div> </div>
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
// ── Polarity theming — stage qualifier text ────────────────────────────── // // ── Polarity theming — stage qualifier text ────────────────────────────── //
// //
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the // On mouseenter, updateStage() injects "Elevated" or "Graven" into the
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot. // sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
// Correspondence field is never populated in sig-select context. // Correspondence field is never populated in sig-select context.
describe("polarity theming — stage qualifier", () => { describe("polarity theming — stage qualifier", () => {
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => { it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
// data-arcana defaults to "Minor Arcana" in fixture → non-major // data-arcana defaults to "Minor Arcana" in fixture → non-major
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened"); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
}); });
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => { it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
}); });
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => { it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Now major — above should be empty, below filled // Now major — above should be empty, below filled
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
}); });
it("correspondence field is never populated", () => { it("correspondence field is never populated", () => {

View File

@@ -24,9 +24,11 @@
<script src="SigSelectSpec.js"></script> <script src="SigSelectSpec.js"></script>
<script src="NatusWheelSpec.js"></script> <script src="NatusWheelSpec.js"></script>
<script src="NoteSpec.js"></script> <script src="NoteSpec.js"></script>
<script src="NotePageSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/dashboard/note.js"></script> <script src="/static/apps/dashboard/note.js"></script>
<script src="/static/apps/billboard/note-page.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script> <script src="/static/apps/epic/sig-select.js"></script>

View File

@@ -557,13 +557,22 @@ 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;
z-index: 10000; z-index: 10000;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
background-color: rgba(var(--tooltip-bg), 0.5); background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4); border: 0.1rem solid rgba(var(--secUser), 0.4);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4); box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
@@ -587,3 +596,8 @@ body {
gap: 0.5rem; gap: 0.5rem;
} }
} }
.card-ref {
color: rgba(var(--terUser), 1) !important;
font-weight: 600 !important;
}

View File

@@ -70,6 +70,8 @@
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
z-index: 1; z-index: 1;
opacity: 0;
transition: opacity 0.15s;
.btn { margin: 0; } .btn { margin: 0; }
} }
@@ -85,19 +87,33 @@
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4); border: 0.1rem solid rgba(var(--secUser), 0.4);
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: help; cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s; transition: border-color 0.15s, box-shadow 0.15s;
&:hover, // Hover glow — only when no note is click-locked on the page
&:active, body:not(.notes-locked) &:hover {
border-color: rgba(var(--terUser), 1);
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
.note-item__title { color: rgba(var(--terUser), 1); }
.note-don-doff { opacity: 1; }
}
// Palette modal open (existing)
&.note-item--active { &.note-item--active {
border-color: rgba(var(--terUser), 1); border-color: rgba(var(--terUser), 1);
opacity: 1;
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35); box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
.note-item__title { color: rgba(var(--terUser), 1); } .note-item__title { color: rgba(var(--terUser), 1); }
} }
// Click-locked: glow + DON/DOFF always visible
&.note-item--locked,
&.note-item--donned {
border-color: rgba(var(--terUser), 1);
box-shadow: 0 0 10px rgba(var(--ninUser), 0.35);
.note-item__title { color: rgba(var(--terUser), 1); }
.note-don-doff { opacity: 1; }
}
.note-item__body { .note-item__body {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -164,6 +180,17 @@
opacity: 0.6; opacity: 0.6;
&:hover { opacity: 1; } &:hover { opacity: 1; }
&--label {
font-size: 1.1rem;
font-weight: bold;
color: rgba(var(--secUser), 0.6);
opacity: 1;
cursor: default;
border-style: solid;
border-color: rgba(var(--secUser), 0.6);
&: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

@@ -738,6 +738,37 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
$card-w: 90px; $card-w: 90px;
$card-h: 60px; $card-h: 60px;
// ─── No-deck warning overlay ──────────────────────────────────────────────
.role-no-deck-warning {
position: fixed;
z-index: 10000;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
max-width: 11rem;
border-radius: 0.5rem;
background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
p {
font-size: 0.75rem;
color: rgba(var(--secUser), 0.9);
text-align: center;
margin: 0;
}
.guard-actions {
display: flex;
gap: 0.5rem;
}
}
// ─── Role select modal ───────────────────────────────────────────────────── // ─── Role select modal ─────────────────────────────────────────────────────
.role-select-backdrop { .role-select-backdrop {

View File

@@ -0,0 +1,204 @@
function flushPromises() {
return new Promise(resolve => setTimeout(resolve, 0));
}
describe("NotePage", () => {
let testDiv, item1, item2, don1, doff1, don2, doff2;
function makeItem(slug, isEquipped) {
const li = document.createElement("li");
li.className = "note-item";
li.dataset.slug = slug;
li.dataset.donUrl = `/billboard/note/${slug}/don`;
li.dataset.doffUrl = `/billboard/note/${slug}/doff`;
li.innerHTML = `
<div class="note-don-doff">
<button class="btn btn-equip note-don-btn${isEquipped ? " btn-disabled" : ""}">${isEquipped ? "×" : "DON"}</button>
<button class="btn btn-unequip note-doff-btn${isEquipped ? "" : " btn-disabled"}">${isEquipped ? "DOFF" : "×"}</button>
</div>
<div class="note-item__body"><p class="note-item__title">${slug}</p></div>
`;
return li;
}
beforeEach(() => {
NotePage._testReset();
testDiv = document.createElement("div");
testDiv.innerHTML = `<span id="id_greeting_prefix">Welcome,</span><span id="id_greeting_name">Earthman</span>`;
item1 = makeItem("super-schizo", false);
item2 = makeItem("super-nomad", false);
testDiv.appendChild(item1);
testDiv.appendChild(item2);
document.body.appendChild(testDiv);
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ title: "Schizoid Man", greeting: "21st Century" }) })
);
don1 = item1.querySelector(".note-don-btn");
doff1 = item1.querySelector(".note-doff-btn");
don2 = item2.querySelector(".note-don-btn");
doff2 = item2.querySelector(".note-doff-btn");
NotePage._init();
});
afterEach(() => {
testDiv.remove();
document.body.classList.remove("notes-locked");
delete window.fetch;
});
// ── Lock / unlock ─────────────────────────────────────────────────────────
describe("click-lock behaviour", () => {
it("clicking a note adds note-item--locked", () => {
item1.click();
expect(item1.classList.contains("note-item--locked")).toBe(true);
});
it("clicking a note adds notes-locked to body", () => {
item1.click();
expect(document.body.classList.contains("notes-locked")).toBe(true);
});
it("clicking the same note again removes lock", () => {
item1.click();
item1.click();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(document.body.classList.contains("notes-locked")).toBe(false);
});
it("clicking a different note moves lock to that note", () => {
item1.click();
item2.click();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(item2.classList.contains("note-item--locked")).toBe(true);
});
it("body click clears all locks", () => {
item1.click();
document.body.click();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(document.body.classList.contains("notes-locked")).toBe(false);
});
});
// ── DON/DOFF state ────────────────────────────────────────────────────────
describe("DON button", () => {
it("clicking DON sends POST to don URL", async () => {
don1.click();
await flushPromises();
expect(window.fetch).toHaveBeenCalledWith(
"/billboard/note/super-schizo/don",
jasmine.objectContaining({ method: "POST" })
);
});
it("after DON, note gains note-item--donned", async () => {
don1.click();
await flushPromises();
expect(item1.classList.contains("note-item--donned")).toBe(true);
});
it("after DON, lock is cleared", async () => {
item1.click(); // lock first
don1.click();
await flushPromises();
expect(item1.classList.contains("note-item--locked")).toBe(false);
expect(document.body.classList.contains("notes-locked")).toBe(false);
});
it("after DON, greeting prefix and name are updated", async () => {
don1.click();
await flushPromises();
expect(document.getElementById("id_greeting_prefix").innerHTML).toContain("21st Century");
expect(document.getElementById("id_greeting_name").textContent).toBe("Schizoid Man");
});
it("DONning item1 auto-removes donned state from previously DONned item2", async () => {
item2.classList.add("note-item--donned");
don2.classList.add("btn-disabled"); don2.textContent = "×";
doff2.classList.remove("btn-disabled"); doff2.textContent = "DOFF";
NotePage._donnedItem = item2;
don1.click();
await flushPromises();
expect(item2.classList.contains("note-item--donned")).toBe(false);
expect(don2.classList.contains("btn-disabled")).toBe(false);
expect(don2.textContent).toBe("DON");
expect(doff2.classList.contains("btn-disabled")).toBe(true);
});
it("DONning one does not POST a doff for the previous", async () => {
item2.classList.add("note-item--donned");
NotePage._donnedItem = item2;
don1.click();
await flushPromises();
// Only one fetch call (the DON), no DOFF call for item2
expect(window.fetch.calls.count()).toBe(1);
});
});
describe("DOFF button", () => {
it("clicking DOFF sends POST to doff URL", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
);
item1.classList.add("note-item--donned");
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
don1.classList.add("btn-disabled"); don1.textContent = "×";
NotePage._donnedItem = item1;
doff1.click();
await flushPromises();
expect(window.fetch).toHaveBeenCalledWith(
"/billboard/note/super-schizo/doff",
jasmine.objectContaining({ method: "POST" })
);
});
it("after DOFF, note loses note-item--donned", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
);
item1.classList.add("note-item--donned");
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
NotePage._donnedItem = item1;
doff1.click();
await flushPromises();
expect(item1.classList.contains("note-item--donned")).toBe(false);
});
it("after DOFF, greeting reverts to Welcome, / Earthman", async () => {
window.fetch = jasmine.createSpy("fetch").and.returnValue(
Promise.resolve({ ok: true, json: () => Promise.resolve({ greeting: "Welcome,", title: "Earthman" }) })
);
document.getElementById("id_greeting_prefix").textContent = "21st Century";
document.getElementById("id_greeting_name").textContent = "Schizoid Man";
item1.classList.add("note-item--donned");
doff1.classList.remove("btn-disabled"); doff1.textContent = "DOFF";
NotePage._donnedItem = item1;
doff1.click();
await flushPromises();
expect(document.getElementById("id_greeting_prefix").textContent).toBe("Welcome,");
expect(document.getElementById("id_greeting_name").textContent).toBe("Earthman");
});
});
// ── Initial donned state ──────────────────────────────────────────────────
describe("initial load with an already-donned note", () => {
it("note whose DON is btn-disabled gets note-item--donned on init", () => {
const equippedItem = makeItem("stargazer", true);
testDiv.appendChild(equippedItem);
NotePage._testReset();
NotePage._init();
expect(equippedItem.classList.contains("note-item--donned")).toBe(true);
});
});
});

View File

@@ -21,6 +21,8 @@ describe("RoleSelect", () => {
afterEach(() => { afterEach(() => {
RoleSelect.closeFan(); RoleSelect.closeFan();
testDiv.remove(); testDiv.remove();
const warning = document.querySelector(".role-no-deck-warning");
if (warning) warning.remove();
delete window.showGuard; delete window.showGuard;
}); });
@@ -693,4 +695,87 @@ describe("RoleSelect", () => {
}); });
}); });
}); });
// ------------------------------------------------------------------ //
// No-deck guard //
// data-equipped-deck="" → openFan() blocked; warning shown over stack //
// ------------------------------------------------------------------ //
describe("openFan() — no deck equipped (data-equipped-deck='')", () => {
let stack;
beforeEach(() => {
stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
stack.dataset.equippedDeck = ""; // explicitly empty = no deck
testDiv.appendChild(stack);
RoleSelect.openFan(); // intercepted — fan must NOT open
});
it("does not open the fan backdrop", () => {
expect(document.querySelector(".role-select-backdrop")).toBeNull();
});
it("shows .role-no-deck-warning in the DOM", () => {
expect(document.querySelector(".role-no-deck-warning")).not.toBeNull();
});
it("warning contains the deck prompt text", () => {
const w = document.querySelector(".role-no-deck-warning");
expect(w.textContent).toContain("Equip card deck before Role select");
});
it("warning has a .btn-caution FYI link to gameboard", () => {
const btn = document.querySelector(".role-no-deck-warning .guard-actions .btn-caution");
expect(btn).not.toBeNull();
expect(btn.tagName).toBe("A");
expect(btn.href).toContain("/gameboard/");
});
it("warning has a .btn-cancel NVM button", () => {
expect(document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel")).not.toBeNull();
});
it("NVM click removes the warning", () => {
document.querySelector(".role-no-deck-warning .guard-actions .btn-cancel").click();
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
});
it("second openFan() call does not duplicate the warning", () => {
RoleSelect.openFan();
expect(document.querySelectorAll(".role-no-deck-warning").length).toBe(1);
});
it("does not POST to select_role", () => {
expect(window.fetch).not.toHaveBeenCalled();
});
});
describe("openFan() — deck is equipped (data-equipped-deck non-empty)", () => {
beforeEach(() => {
const stack = document.createElement("div");
stack.className = "card-stack";
stack.dataset.state = "eligible";
stack.dataset.starterRoles = "";
stack.dataset.userSlots = "1";
stack.dataset.activeSlot = "1";
stack.dataset.equippedDeck = "42"; // non-empty = deck present
testDiv.appendChild(stack);
RoleSelect.openFan();
});
it("opens the fan backdrop normally", () => {
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
});
it("does not show .role-no-deck-warning", () => {
expect(document.querySelector(".role-no-deck-warning")).toBeNull();
});
});
}); });

View File

@@ -58,7 +58,9 @@ describe("SigSelect", () => {
data-correspondence="" data-correspondence=""
data-keywords-upright="action,impulsiveness,ambition" data-keywords-upright="action,impulsiveness,ambition"
data-keywords-reversed="no direction,disregard for consequences" data-keywords-reversed="no direction,disregard for consequences"
data-cautions="${cardCautions.replace(/"/g, '&quot;')}"> data-cautions="${cardCautions.replace(/"/g, '&quot;')}"
data-levity-qualifier="Elevated"
data-gravity-qualifier="Graven">
<div class="fan-card-corner fan-card-corner--tl"> <div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">K</span> <span class="fan-corner-rank">K</span>
</div> </div>
@@ -540,25 +542,25 @@ describe("SigSelect", () => {
// ── Polarity theming — stage qualifier text ────────────────────────────── // // ── Polarity theming — stage qualifier text ────────────────────────────── //
// //
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the // On mouseenter, updateStage() injects "Elevated" or "Graven" into the
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot. // sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
// Correspondence field is never populated in sig-select context. // Correspondence field is never populated in sig-select context.
describe("polarity theming — stage qualifier", () => { describe("polarity theming — stage qualifier", () => {
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => { it("levity non-major card puts 'Elevated' in qualifier-above, qualifier-below empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
// data-arcana defaults to "Minor Arcana" in fixture → non-major // data-arcana defaults to "Minor Arcana" in fixture → non-major
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened"); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Elevated");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
}); });
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => { it("levity major arcana card puts 'Elevated' in qualifier-below, qualifier-above empty", () => {
makeFixture({ polarity: 'levity', userRole: 'PC' }); makeFixture({ polarity: 'levity', userRole: 'PC' });
card.dataset.arcana = "Major Arcana"; card.dataset.arcana = "Major Arcana";
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
}); });
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => { it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
@@ -597,7 +599,7 @@ describe("SigSelect", () => {
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true })); card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
// Now major — above should be empty, below filled // Now major — above should be empty, below filled
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe(""); expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened"); expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Elevated");
}); });
it("correspondence field is never populated", () => { it("correspondence field is never populated", () => {

View File

@@ -24,9 +24,11 @@
<script src="SigSelectSpec.js"></script> <script src="SigSelectSpec.js"></script>
<script src="NatusWheelSpec.js"></script> <script src="NatusWheelSpec.js"></script>
<script src="NoteSpec.js"></script> <script src="NoteSpec.js"></script>
<script src="NotePageSpec.js"></script>
<!-- src files --> <!-- src files -->
<script src="/static/apps/dashboard/dashboard.js"></script> <script src="/static/apps/dashboard/dashboard.js"></script>
<script src="/static/apps/dashboard/note.js"></script> <script src="/static/apps/dashboard/note.js"></script>
<script src="/static/apps/billboard/note-page.js"></script>
<script src="/static/apps/epic/role-select.js"></script> <script src="/static/apps/epic/role-select.js"></script>
<script src="/static/apps/epic/tray.js"></script> <script src="/static/apps/epic/tray.js"></script>
<script src="/static/apps/epic/sig-select.js"></script> <script src="/static/apps/epic/sig-select.js"></script>

View File

@@ -25,11 +25,11 @@
<div class="note-item__body"> <div class="note-item__body">
<p class="note-item__title">{{ item.title }}</p> <p class="note-item__title">{{ item.title }}</p>
<p class="note-item__description">{{ item.description }}</p> <p class="note-item__description">{{ item.description|safe }}</p>
<div class="note-recognitions"> <div class="note-recognitions">
<div class="note-recognitions__header">Recognitions</div> <div class="note-recognitions__header">Recognitions</div>
<ul class="note-recognitions__list"> <ul class="note-recognitions__list">
<li><span class="note-recognitions__dim">Title:</span> <strong>{{ item.title }}</strong></li> <li><span class="note-recognitions__dim">Title:</span> <strong>{{ item.recognition_title }}</strong></li>
{% if item.obj.palette %} {% if item.obj.palette %}
<li class="note-recognitions__palette-line"><span class="note-recognitions__dim">Palette:</span> <strong>{{ item.palette_label }}</strong></li> <li class="note-recognitions__palette-line"><span class="note-recognitions__dim">Palette:</span> <strong>{{ item.palette_label }}</strong></li>
{% endif %} {% endif %}
@@ -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

@@ -3,7 +3,7 @@
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2><a href="{% url 'game_kit' %}">Game Kit</a></h2> <h2><a href="{% url 'game_kit' %}">Game Kit</a></h2>
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id|default:'' }}" data-equipped-deck-id="{{ equipped_deck_id|default:'' }}"> <div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id|default:'' }}" data-equipped-deck-id="{{ equipped_deck_id|default:'' }}" data-in-use-deck-ids="{% for d in deck_variants %}{% if d.in_use_room_name %}{{ d.pk }},{% endif %}{% endfor %}">
{% if pass_token %} {% if pass_token %}
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}"> <div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
@@ -21,11 +21,11 @@
</div> </div>
{% endif %} {% endif %}
{% if carte %} {% if carte %}
<div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}"> <div id="id_kit_carte_blanche" class="token" data-token-id="{{ carte.pk }}" data-current-room-name="{{ carte.current_room.name|default:'' }}">
<i class="fa-solid fa-money-check"></i> <i class="fa-solid fa-money-check"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns"> <div class="tt-equip-btns">
{% if carte.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ carte.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ carte.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% endif %} {% if carte.current_room %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% elif carte.pk == equipped_trinket_id %}<button class="btn btn-equip btn-disabled" data-token-id="{{ carte.pk }}">×</button><button class="btn btn-unequip" data-token-id="{{ carte.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-token-id="{{ carte.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-token-id="{{ carte.pk }}">×</button>{% endif %}
</div> </div>
<h4 class="tt-title">{{ carte.tooltip_name }}</h4> <h4 class="tt-title">{{ carte.tooltip_name }}</h4>
<p class="tt-description">{{ carte.tooltip_description }}</p> <p class="tt-description">{{ carte.tooltip_description }}</p>
@@ -33,6 +33,7 @@
<p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p> <p class="tt-shoptalk"><em>{{ carte.tooltip_shoptalk }}</em></p>
{% endif %} {% endif %}
<p class="tt-expiry">{{ carte.tooltip_expiry }}</p> <p class="tt-expiry">{{ carte.tooltip_expiry }}</p>
{% if carte.current_room %}<p class="tt-token-room-name">In game: {{ carte.current_room.name }}</p>{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -72,10 +73,13 @@
<i class="fa-regular fa-id-badge"></i> <i class="fa-regular fa-id-badge"></i>
<div class="tt"> <div class="tt">
<div class="tt-equip-btns"> <div class="tt-equip-btns">
{% if deck.pk == equipped_deck_id %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip" data-deck-id="{{ deck.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-deck-id="{{ deck.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% endif %} {% if deck.in_use_room_name %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% elif deck.pk == equipped_deck_id %}<button class="btn btn-equip btn-disabled" data-deck-id="{{ deck.pk }}">×</button><button class="btn btn-unequip" data-deck-id="{{ deck.pk }}">DOFF</button>{% else %}<button class="btn btn-equip" data-deck-id="{{ deck.pk }}">DON</button><button class="btn btn-unequip btn-disabled" data-deck-id="{{ deck.pk }}">×</button>{% endif %}
</div> </div>
<h4 class="tt-title">{{ deck.name }}</h4> <h4 class="tt-title">{{ deck.name }}{% if deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
<p class="tt-description">{{ deck.card_count }} cards</p> <p class="tt-description">{{ deck.card_count }}-card Tarot deck</p>
{% if deck.description %}<p class="tt-shoptalk"><em>{{ deck.description }}</em></p>{% endif %}
<p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p>
{% if deck.in_use_room_name %}<p class="tt-deck-game-name">In game: {{ deck.in_use_room_name }}</p>{% endif %}
</div> </div>
</div> </div>
{% empty %} {% empty %}

View File

@@ -32,13 +32,11 @@
<div class="sig-stage-card" style="--sig-card-w: 4rem"> <div class="sig-stage-card" style="--sig-card-w: 4rem">
{% if my_tray_sig %} {% if my_tray_sig %}
<div class="fan-card-face"> <div class="fan-card-face">
{% if my_tray_sig.arcana == "MIDDLE" %} {% if my_tray_sig.arcana == "MAJOR" %}
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}Leavened{% else %}Graven{% endif %}</p>
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p> <p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
{% elif my_tray_sig.arcana == "MAJOR" %} <p class="sig-qualifier-below">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
<p class="sig-qualifier-below">{% if user_polarity == "levity" %}Leavened{% else %}Graven{% endif %}</p>
{% else %} {% else %}
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}{{ my_tray_sig.levity_qualifier }}{% else %}{{ my_tray_sig.gravity_qualifier }}{% endif %}</p>
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p> <p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
{% endif %} {% endif %}
</div> </div>

View File

@@ -72,7 +72,9 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
data-correspondence="{{ card.correspondence|default:'' }}" data-correspondence="{{ card.correspondence|default:'' }}"
data-keywords-upright="{{ card.keywords_upright|join:',' }}" data-keywords-upright="{{ card.keywords_upright|join:',' }}"
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}" data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
data-cautions="{{ card.cautions_json }}"> data-cautions="{{ card.cautions_json }}"
data-levity-qualifier="{{ card.levity_qualifier }}"
data-gravity-qualifier="{{ card.gravity_qualifier }}">
<div class="fan-card-corner fan-card-corner--tl"> <div class="fan-card-corner fan-card-corner--tl">
<span class="fan-corner-rank">{{ card.corner_rank }}</span> <span class="fan-corner-rank">{{ card.corner_rank }}</span>
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %} {% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}

View File

@@ -27,7 +27,8 @@
<div class="card-stack" data-state="{{ card_stack_state }}" <div class="card-stack" data-state="{{ card_stack_state }}"
data-starter-roles="{{ starter_roles|join:',' }}" data-starter-roles="{{ starter_roles|join:',' }}"
data-user-slots="{{ user_slots|join:',' }}" data-user-slots="{{ user_slots|join:',' }}"
data-active-slot="{{ active_slot }}"> data-active-slot="{{ active_slot }}"
data-equipped-deck="{{ equipped_deck_id|default:'' }}">
{% if card_stack_state == "ineligible" %} {% if card_stack_state == "ineligible" %}
<i class="fa-solid fa-ban"></i> <i class="fa-solid fa-ban"></i>
{% endif %} {% endif %}

View File

@@ -1,20 +1,23 @@
{% if equipped_deck %}
<div class="kit-bag-section"> <div class="kit-bag-section">
<span class="kit-bag-label">Deck</span> <span class="kit-bag-label">Deck</span>
<div class="kit-bag-row"> <div class="kit-bag-row">
{% if equipped_deck %}
<div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}"> <div class="kit-bag-deck" data-deck-id="{{ equipped_deck.pk }}">
<i class="fa-regular fa-id-badge"></i> <i class="fa-regular fa-id-badge"></i>
<div class="tt"> <div class="tt">
<h4 class="tt-title">{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4> <h4 class="tt-title">{{ equipped_deck.name }}{% if equipped_deck.is_default %} <span class="token-count">(Default)</span>{% endif %}</h4>
<p class="tt-description">{{ equipped_deck.card_count }}-card Tarot deck</p> <p class="tt-description">{{ equipped_deck.card_count }}-card Tarot deck</p>
<p class="tt-shoptalk"><em>placeholder comment</em></p> {% if equipped_deck.description %}<p class="tt-shoptalk"><em>{{ equipped_deck.description }}</em></p>{% endif %}
<p class="tt-effect">active</p> <p class="tt-shoptalk">Stock version <span class="tt-subcounter">(0 substitutions)</span></p>
<p class="tt-expiry">Stock version</p>
</div> </div>
</div> </div>
{% else %}
<div class="kit-bag-placeholder">
<i class="fa-regular fa-id-badge"></i>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endif %}
<div class="kit-bag-section"> <div class="kit-bag-section">
<span class="kit-bag-label">Dice</span> <span class="kit-bag-label">Dice</span>

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><span id="id_greeting_prefix">{% if user.active_title %}{{ user.active_title.display_greeting|safe }}{% else %}Welcome,{% endif %}</span><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">