2026-01-30 16:21:32 -05:00
|
|
|
|
import uuid
|
2026-02-19 20:31:29 -05:00
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
|
from datetime import timedelta
|
2026-02-19 20:31:29 -05:00
|
|
|
|
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
|
2026-05-21 00:35:55 -04:00
|
|
|
|
from django.core.exceptions import ValidationError
|
2026-01-29 15:21:54 -05:00
|
|
|
|
from django.db import models
|
2026-03-08 15:14:41 -04:00
|
|
|
|
from django.db.models.signals import post_save
|
|
|
|
|
|
from django.dispatch import receiver
|
2026-03-13 17:31:52 -04:00
|
|
|
|
from django.urls import reverse
|
2026-03-08 15:14:41 -04:00
|
|
|
|
from django.utils import timezone
|
2026-01-29 15:21:54 -05:00
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
|
# ── Pronoun preference set ────────────────────────────────────────────────
|
|
|
|
|
|
# Drives provenance prose ("embodies as their Significator …") and any other
|
|
|
|
|
|
# user-facing referent. Default is pluralism (singular they) so a brand-new
|
|
|
|
|
|
# account renders neutrally; bawlmorese (yo/yo/yos) is the original Earthman
|
|
|
|
|
|
# default kept available as a Baltimore-flavoured option.
|
|
|
|
|
|
|
|
|
|
|
|
PRONOUN_CHOICES = [
|
|
|
|
|
|
("pluralism", "they/them/their"),
|
|
|
|
|
|
("bawlmorese", "yo/yo/yos"),
|
|
|
|
|
|
("misogyny", "he/him/his"),
|
|
|
|
|
|
("misandry", "she/her/hers"),
|
|
|
|
|
|
("misanthropy", "it/it/its"),
|
|
|
|
|
|
]
|
|
|
|
|
|
PRONOUN_TABLE = {
|
|
|
|
|
|
"pluralism": {"subj": "they", "obj": "them", "poss": "their"},
|
|
|
|
|
|
"bawlmorese": {"subj": "yo", "obj": "yo", "poss": "yos"},
|
|
|
|
|
|
"misogyny": {"subj": "he", "obj": "him", "poss": "his"},
|
|
|
|
|
|
"misandry": {"subj": "she", "obj": "her", "poss": "hers"},
|
|
|
|
|
|
"misanthropy": {"subj": "it", "obj": "it", "poss": "its"},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def resolve_pronouns(pronouns_key):
|
|
|
|
|
|
"""Return (subj, obj, poss) for a pronouns key, defaulting to pluralism."""
|
|
|
|
|
|
row = PRONOUN_TABLE.get(pronouns_key) or PRONOUN_TABLE["pluralism"]
|
|
|
|
|
|
return row["subj"], row["obj"], row["poss"]
|
|
|
|
|
|
|
|
|
|
|
|
|
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
|
|
|
|
# ── Reserved usernames ────────────────────────────────────────────────────
|
|
|
|
|
|
# Sitewide entities that shouldn't be impersonated by new account names.
|
|
|
|
|
|
# Compared lower-case in username assignment paths (set_profile, etc.).
|
|
|
|
|
|
# `adman` is the system author for Note-unlock + share-invite Lines (seeded
|
|
|
|
|
|
# in lyric/0003_seed_adman). The author's handles (disco, discoman,
|
|
|
|
|
|
# hamildong) are NOT in this set yet — discoman is the founder's actual
|
|
|
|
|
|
# username and existing tests assign it; revisit if/when other-entity
|
|
|
|
|
|
# impersonation becomes a concrete concern.
|
|
|
|
|
|
|
2026-05-27 13:14:06 -04:00
|
|
|
|
RESERVED_USERNAMES = frozenset({"adman", "taxman", "mailman"})
|
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_reserved_username(name, current_user=None):
|
|
|
|
|
|
"""True if `name` is reserved AND not already owned by `current_user`."""
|
|
|
|
|
|
n = (name or "").strip().lower()
|
|
|
|
|
|
if not n:
|
|
|
|
|
|
return False
|
|
|
|
|
|
if current_user is not None and (current_user.username or "").lower() == n:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return n in RESERVED_USERNAMES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_or_create_adman():
|
|
|
|
|
|
"""Idempotent fetch of the sitewide `adman` User — system-author for
|
|
|
|
|
|
Note-unlock + share-invite Lines. Production migrations seed it once
|
|
|
|
|
|
(lyric/0003_seed_adman); TransactionTestCase flushes the row between
|
|
|
|
|
|
tests, so view code that authors Lines as adman calls this helper."""
|
|
|
|
|
|
from django.contrib.auth.hashers import make_password
|
|
|
|
|
|
adman, _ = User.objects.get_or_create(
|
|
|
|
|
|
username="adman",
|
|
|
|
|
|
defaults={
|
|
|
|
|
|
"email": "adman@earthmanrpg.local",
|
|
|
|
|
|
"password": make_password(None),
|
|
|
|
|
|
"is_staff": False,
|
|
|
|
|
|
"is_superuser": False,
|
|
|
|
|
|
"searchable": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return adman
|
|
|
|
|
|
|
|
|
|
|
|
|
@taxman Debits & credits ledger + NVM-persistent FREE/PAID DRAW Briefs — TDD
User-spec 2026-05-26 for /gameboard/my-sea/. The transient "Free draw locked" Brief that re-appeared on every page load is replaced by a server-driven Brief whose NVM dismissal persists per-cycle, AND every spend now lands a permanent line on a new @taxman-authored "Debits & credits" Post (so the info goes somewhere instead of vanishing on dismiss). Same NVM-persistence treatment for the new PAID DRAW Brief.
Lyric:
- RESERVED_USERNAMES adds "taxman"; get_or_create_taxman() parallels get_or_create_adman() (username=taxman, email=taxman@earthmanrpg.local, unusable password, searchable=False).
- New nullable User.{free,paid}_draw_brief_dismissed_at DateTimeFields — anchor stamps for the NVM-persistence semantics. Cleared by my_sea_lock (free) / my_sea_paid_draw (paid) on each fresh spend so the new cycle re-opens the Brief surface.
- Migration 0014_brief_dismissal_fields adds the fields + RunPython seeds @taxman (mirror of 0003_seed_adman).
Billboard:
- Post.KIND_TAX_LEDGER + TAX_LEDGER_POST_TITLE = "Debits & credits"; Brief.KIND_TAX_LEDGER for routing.
- _delete_unsolicited_admin_post_lines extended via _SYSTEM_AUTHOR_POST_KINDS tuple — TAX_LEDGER joins NOTE_UNLOCK in the post_save guard that nukes any Line w.o. admin_solicited=True.
- Brief.to_banner_dict adds dismiss_url slot (empty by default; populated by the gameboard view for TAX_LEDGER briefs) + uses line.display_text instead of line.text so the prefix is stripped on the banner too.
- Line.display_text property — strips the leading "[iso-timestamp] " prefix that log_tax_debit bakes into TAX_LEDGER Lines (the prefix exists ONLY to satisfy unique_together = (post, text) on repeat-slug spends; the per-Brief + per-Line created_at slots already render the user-facing moment). Identity for non-tax Lines.
- view_post / delete_post / abandon_post guards extended to treat TAX_LEDGER like NOTE_UNLOCK (POST forbidden, can't delete, can't bye).
- Migration 0008_tax_ledger_kind registers the new choices on Post.kind + Brief.kind.
Billboard tax module (new apps/billboard/tax.py):
- TAX_DEBIT_TEMPLATES — canonical body text per slug, with FREE DRAW / PAID DRAW / GATE VIEW button-labels wrapped in .btn-pri-name spans:
- free_draw_locked → "Look!—my_sea.html [FREE DRAW] is locked. Next free draw available 24h from the production of this log."
- paid_draw_locked → "Look!—my_sea.html [PAID DRAW] is locked. Another may be unlocked by depositing a Token in [GATE VIEW]."
- log_tax_debit(user, slug) — get-or-creates the user's TAX_LEDGER Post, appends a timestamp-prefixed Line authored by @taxman w. admin_solicited=True, spawns a Brief. Returns (post, line, brief).
Gameboard:
- my_sea_lock first-card-of-cycle branch calls log_tax_debit(user, "free_draw_locked") + clears free_draw_brief_dismissed_at. Response now includes free_draw_brief_payload (Brief.to_banner_dict w. dismiss_url populated) so the picker IIFE can surface the new Brief in-place w.o. a page reload — same affordance the prior _showFreeDrawLockedBrief provided, w. server-authored copy + NVM-persistence.
- my_sea_paid_draw after paid_through_at stamp calls log_tax_debit(user, "paid_draw_locked") + clears paid_draw_brief_dismissed_at. Next-page-load surfaces the new Brief via the context payload.
- New my_sea_dismiss_free_draw_brief + my_sea_dismiss_paid_draw_brief POST endpoints stamp the matching User anchor field; return 204. URLs at /gameboard/my-sea/brief/{free,paid}-draw/dismiss.
- my_sea view's context computes {free,paid}_draw_brief_payload via the new _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url) helper — returns the latest TAX_LEDGER Brief's to_banner_dict IF (dismissal anchor is None OR anchor < brief.created_at). Slug discrimination via line__text__contains="FREE DRAW" / "PAID DRAW" (kept the Brief schema flat — only two markers today, non-overlapping wordings).
Frontend (apps/dashboard/static/apps/dashboard/note.js):
- Brief.showBanner NVM handler now fires a fire-and-forget POST to brief.dismiss_url (if present) before removing the banner. Persistent-NVM kinds (TAX_LEDGER) supply it; transient kinds leave the field empty + the handler no-ops to the existing dismiss-only behavior. CSRF token pulled from the csrftoken cookie.
SCSS (static_src/scss/_billboard.scss):
- .post-line--system .post-line-text .btn-pri-name — inline emphasis (color: --quaUser, font-weight: 700, font-style: normal) on canonical .btn-primary button labels referenced in @taxman ledger prose. User-spec 2026-05-26 mid-flight clarification: log surface only, not the actual buttons.
Templates:
- templates/apps/gameboard/my_sea.html: replaces the inline _showFreeDrawLockedBrief({{ next_free_draw_at|date:'c' }}) invocation w. two {% if *_brief_payload %} blocks that json_script the payload + dispatch via a new _showTaxBrief(payload, bannerClass) helper. _postLock updated to call _showFreeDrawLockedBrief(body.free_draw_brief_payload) so freshly-emitted Briefs surface in-place w.o. a reload (same affordance as before, w. server payload).
- templates/apps/billboard/post.html: readonly-textarea / system-author-styling / bud-panel-suppression branches all extended to cover post.kind == 'tax_ledger' (parallel to existing 'note_unlock' cases). Line-text rendering uses line.display_text (strips the iso prefix) + treats @taxman the same as @adman (allow HTML rendering for the system-author safe text — required so the .btn-pri-name spans aren't escaped).
Tests:
UTs (apps/billboard/tests/integrated/test_tax.py — 11 specs):
- log_tax_debit creates Post/Line/Brief w. correct kind + author + admin_solicited.
- Both slug templates produce expected text (assertions tolerant of inline .btn-pri-name span HTML).
- Two spends share one Post w. two distinct Lines (timestamp prefix keeps unique_together happy).
- Unknown slug raises KeyError.
- post_save guard nukes unsolicited Lines on TAX_LEDGER Posts; solicited Lines survive.
- "taxman" is reserved (case-insensitive); get_or_create_taxman idempotent.
ITs (apps/gameboard/tests/integrated/test_tax_briefs.py — 13 specs):
- my_sea_lock first-card creates TAX_LEDGER Post + Line + Brief; mid-cycle upserts do NOT emit extra debits; clears free_draw_brief_dismissed_at.
- my_sea_paid_draw commit creates a separate TAX_LEDGER entry; clears paid_draw_brief_dismissed_at.
- Dismiss endpoints stamp the matching User anchor; reject GET (405); require login (302).
- my_sea context: *_brief_payload is None until first spend; populated after; suppressed after NVM-dismiss; returns after cycle reset.
Existing ITs adjusted (apps/gameboard/tests/integrated/test_views.py):
- test_view_triggers_brief_banner_when_active_draw_exists + test_empty_hand_brief_banner_still_triggered + test_view_does_not_trigger_brief_banner_without_active_draw — assertions retargeted from window._showFreeDrawLockedBrief(" to id="id_free_draw_brief_payload" (the new json_script payload tag).
- test_brief_next_free_draw_at_uses_user_anchor_not_paid_row — switched from HTML-substring assertion against the rendered ISO (now absent from the page) to a direct response.context["next_free_draw_at"] comparison. Same underlying invariant; cleaner assertion shape.
FT (functional_tests/test_bill_post_debits_credits.py — 1 spec):
- After two seeded debits, /billboard/post/<uuid>/ renders the "Debits & credits" title, both Line bodies (FREE DRAW + PAID DRAW), @taxman attribution, readonly input w. "No response needed at this time" placeholder, AND verifies the "[iso] " prefix is stripped from display.
All 1340 IT+UT green; new FT green; existing FTs unaffected by these changes.
Pending follow-up (recorded for next sprint):
Per user 2026-05-26 in-flight ask: refactor @adman concerns into apps/billboard/ad.py (paralleling the new apps/billboard/tax.py) — extract Note.grant_if_new's billboard-side concerns (Post/Line/Brief creation, prose templates) out of apps/drama/models.py into the same shape log_tax_debit now follows. Notated for after this sprint lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:26:42 -04:00
|
|
|
|
def get_or_create_taxman():
|
|
|
|
|
|
"""Idempotent fetch of the sitewide `taxman` User — system-author for
|
|
|
|
|
|
Debits & credits ledger Lines (FREE/PAID DRAW spend log per user-spec
|
|
|
|
|
|
2026-05-26). Parallels `get_or_create_adman` exactly; production
|
|
|
|
|
|
migration `lyric/0014_seed_taxman_and_brief_dismissal_fields` seeds
|
|
|
|
|
|
the row once, this helper backstops TransactionTestCase flushes."""
|
|
|
|
|
|
from django.contrib.auth.hashers import make_password
|
|
|
|
|
|
taxman, _ = User.objects.get_or_create(
|
|
|
|
|
|
username="taxman",
|
|
|
|
|
|
defaults={
|
|
|
|
|
|
"email": "taxman@earthmanrpg.local",
|
|
|
|
|
|
"password": make_password(None),
|
|
|
|
|
|
"is_staff": False,
|
|
|
|
|
|
"is_superuser": False,
|
|
|
|
|
|
"searchable": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return taxman
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 13:14:06 -04:00
|
|
|
|
def get_or_create_mailman():
|
|
|
|
|
|
"""Idempotent fetch of the sitewide `mailman` User — system-author for the
|
|
|
|
|
|
"Acceptances & rejections" invite log Lines (my-sea bud-invite flow, see
|
|
|
|
|
|
[[my-sea-invite-voice-blueprint]]). Parallels `get_or_create_taxman`
|
|
|
|
|
|
exactly; production migration `lyric/0015_seed_mailman` seeds the row once,
|
|
|
|
|
|
this helper backstops TransactionTestCase flushes."""
|
|
|
|
|
|
from django.contrib.auth.hashers import make_password
|
|
|
|
|
|
mailman, _ = User.objects.get_or_create(
|
|
|
|
|
|
username="mailman",
|
|
|
|
|
|
defaults={
|
|
|
|
|
|
"email": "mailman@earthmanrpg.local",
|
|
|
|
|
|
"password": make_password(None),
|
|
|
|
|
|
"is_staff": False,
|
|
|
|
|
|
"is_superuser": False,
|
|
|
|
|
|
"searchable": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
return mailman
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
class UserManager(BaseUserManager):
|
|
|
|
|
|
def create_user(self, email):
|
|
|
|
|
|
user = self.model(email=email)
|
|
|
|
|
|
user.set_unusable_password()
|
|
|
|
|
|
user.save(using=self._db)
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
|
|
|
|
|
def create_superuser(self, email, password):
|
|
|
|
|
|
user = self.model(email=email, is_staff=True, is_superuser=True)
|
|
|
|
|
|
user.set_password(password)
|
|
|
|
|
|
user.save(using=self._db)
|
|
|
|
|
|
return user
|
|
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
|
class LoginToken(models.Model):
|
2026-01-30 16:21:32 -05:00
|
|
|
|
email = models.EmailField()
|
2026-01-30 19:10:17 -05:00
|
|
|
|
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
2026-01-30 16:21:32 -05:00
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
class User(AbstractBaseUser):
|
2026-02-07 20:15:27 -05:00
|
|
|
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
2026-01-30 15:04:47 -05:00
|
|
|
|
email = models.EmailField(unique=True)
|
2026-03-01 21:19:12 -05:00
|
|
|
|
username = models.CharField(max_length=35, unique=True, null=True, blank=True)
|
|
|
|
|
|
searchable = models.BooleanField(default=False)
|
2026-03-05 14:45:55 -05:00
|
|
|
|
palette = models.CharField(max_length=32, default="palette-default")
|
2026-03-09 01:07:16 -04:00
|
|
|
|
stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
|
2026-03-16 00:07:52 -04:00
|
|
|
|
equipped_trinket = models.ForeignKey(
|
|
|
|
|
|
"Token", null=True, blank=True,
|
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
|
)
|
2026-03-24 21:07:01 -04:00
|
|
|
|
equipped_deck = models.ForeignKey(
|
|
|
|
|
|
"epic.DeckVariant", null=True, blank=True,
|
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
|
)
|
2026-03-24 22:25:25 -04:00
|
|
|
|
unlocked_decks = models.ManyToManyField(
|
|
|
|
|
|
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
|
|
|
|
|
)
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
# Asymmetric self M2M — `user.buds.all()` = people I've explicitly
|
buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
- applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
- billboard.views.my_buddies + add_buddy:
• my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
• add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
- templates:
• _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
• my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
• _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
- SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
- tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
- 841 ITs (+14) + 4 my_buddies FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:42 -04:00
|
|
|
|
# added (or implicitly via post-share / game-invite, which auto-adds
|
buds rename + applet-list shell — Buddies → Buds everywhere (model field, slug, URL, view, DOM, CSS); my_buds.html + my_posts.html share new _applet-list-shell.html partial — vertical-title applet-scroll card; my_posts hosts two side-by-side in landscape, stacked in portrait — TDD
- lyric/0005 RemoveField+AddField (RenameField doesn't rename the implicit M2M through table; field was new in 0004 so no data loss). Lyric.User.buddies → User.buds; related_name added_as_buddy → added_as_bud.
- applets/0007 renames Applet slug my-buddies → my-buds + name 'My Buddies' → 'My Buds'. UI rationale: BILLBUDDIES overflowed the page-header band; in-game term collapses to BILLBUDS.
- billboard/0006 alter Line.Meta.ordering = ('created_at', 'id') — was already in models.py, just generates the corresponding migration (formalizing the ordering decision from the May-8b refactor).
- global rename via sed: buddies → buds, buddy → bud across 16 files (templates, SCSS, JS, ITs, FTs, page object, view code). 4 file renames via git mv: my_buddies.html → my_buds.html, _applet-my-buddies.html → _applet-my-buds.html, _buddy_panel.html → _bud_panel.html, _buddy_add_panel.html → _bud_add_panel.html, _buddy.scss → _bud.scss. Test files renamed too: test_buddies.py → test_buds.py, test_my_buddies.py → test_my_buds.py, test_buddy_btn.py → test_bud_btn.py. core.scss @import 'buddy' → 'bud'.
- new shared partial templates/apps/applets/_partials/_applet-list-shell.html — vertical-rotated <h2> + scrollable <ul> aperture, parameterised via {% include %} so a single page can invoke it more than once. Params: shell_title, shell_items, shell_item_template, shell_list_id, shell_empty.
- my_buds.html: single shell invocation w. add-bud panel below (page_class page-billbuds).
- my_posts.html: two shell invocations (own posts + posts shared with me) inside .applet-list-page--two-up — portrait stacks them; landscape lays side-by-side via @media (orientation: landscape) flex-direction: row (page_class page-billposts).
- SCSS: drop the bottom-anchored .buds-page block; new shared .applet-list-page (extends %billboard-page-base, flex-column + padding) w. .applet-scroll inside (extends %applet-box) and .applet-list inside that (flex: 1, overflow-y: auto). .applet-list-page--two-up flips to row layout in landscape. Body class trio gains page-billposts.
- 841 ITs + 5 my_buds/my_posts FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 23:08:33 -04:00
|
|
|
|
# the recipient to the inviter's buds list). `user.added_as_bud` is
|
|
|
|
|
|
# the inverse (people who have me in their buds list); useful for
|
|
|
|
|
|
# the future "bud changed username" snapshot-accept flow.
|
|
|
|
|
|
buds = models.ManyToManyField(
|
|
|
|
|
|
"self", symmetrical=False, blank=True, related_name="added_as_bud",
|
buddies sprint phase 1: User.buddies M2M(self,symm=False) + my_buddies aperture page + add_buddy JSON endpoint + buddy btn slide-out — TDD; My Contacts applet renamed → My Buddies (slug + name + partial)
- lyric/0004 adds User.buddies = ManyToManyField('self', symmetrical=False, blank=True, related_name='added_as_buddy'). Asymmetric one-way add: A.buddies.add(B) doesn't reciprocate. Reverse via B.added_as_buddy.all() — load-bearing for the future "buddy changed username" snapshot-accept flow noted in design.
- applets/0006 renames slug my-contacts → my-buddies + name 'Contacts' → 'My Buddies'. Existing migrations 0003/0004 untouched (historical artifacts).
- billboard.views.my_buddies + add_buddy:
• my_buddies: GET /billboard/my-buddies/ → renders the aperture page with request.user.buddies.all().
• add_buddy: POST /billboard/buddies/add → JSON {buddy: {id, username, email}|null}. Privacy: returns null when email isn't a registered User OR is the requester's own; never leaks membership. Idempotent on re-add (M2M dedup).
- templates:
• _applet-my-contacts.html → _applet-my-buddies.html (heading + link to /billboard/my-buddies/).
• my_buddies.html — bottom-anchored aperture list of buddies w. {% empty %} fallback "No buddies yet."
• _buddy_add_panel.html — bottom-left handshake btn + slide-out, mirrors _buddy_panel.html (post share) but POSTs to add_buddy and appends to #id_buddies_list. Skips append if data-buddy-id already in DOM (race-safe). Drops the .buddy-entry--empty row on first add.
- SCSS: page-billbuddies joins the body-class aperture trio; .buddies-page extends %billboard-page-base + flex-column + bottom-anchor for #id_buddies_list. id_applet_my_contacts → id_applet_my_buddies (test references + grid placement).
- tests: new test_buddies.py — 14 ITs covering UserBuddiesM2MTest (asymmetric, idempotent), MyBuddiesViewTest (lists own buddies only, anon redirect), AddBuddyViewTest (registered/unregistered/self/idempotent/email-fallback/405). Existing test_views/test_billboard/test_game_kit references swapped to my-buddies. New test_my_buddies.py FT — 4 tests: pre-existing buddies render, empty state, add via panel appends entry w. username, unregistered silent no-op.
- 841 ITs (+14) + 4 my_buddies FTs green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:31:42 -04:00
|
|
|
|
)
|
2026-04-23 01:44:58 -04:00
|
|
|
|
active_title = models.ForeignKey(
|
|
|
|
|
|
"drama.Note", null=True, blank=True,
|
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
|
)
|
Game Sign picker @ /billboard/my-sign/ + billboard applet — Sprint 4a of My Sea roadmap — TDD
User scope (per design conv this session): split the room's sig-select responsibility off into a standalone billboard-context "My Significator" applet — branded "Game Sign" on the surface. Same 18-card pile as room sig-select (16 middle arcana + Major 0 & 1 filtered by Note unlocks); polarity collapses to a single FLIP choice (the FLIP btn in the picker carousel toggles User.significator_reversed). Selection persists globally on the User model + propagates to the billboard's Game Sign applet ; **naming convention locked**: "significator" stays at storage (User.significator FK + User.significator_reversed) + room sig-select context (DRY w. existing template/JS); "Sign" / "Game Sign" is the billboard-surface branding (file my_sign.html, URL /billboard/my-sign/, URL names my_sign + save_sign, applet name "Game Sign", page wordmark "Game Sign", btn label SAVE SIGN). Action URLs don't carry a trailing slash per project convention (/billboard/my-sign/save vs the page's /billboard/my-sign/) ; **schema**: User gains 2 fields — `significator: FK → epic.TarotCard (nullable, on_delete=SET_NULL)` + `significator_reversed: BooleanField(default=False)`. Migration lyric/0006_user_significator_user_significator_reversed.py auto-generated; reversible. Applet seed in applets/0009_seed_my_sig_applet.py adds the row (slug='my-sign', name='Game Sign', context='billboard', default_visible=True, grid_cols=4, grid_rows=6), idempotent update_or_create, reversible unseed() ; **picker page** (my_sign.html): solo lift of `_sig_select_overlay.html` — sig-stage-card scaffold + sig-stat-block + 18-card grid + SAVE SIGN form. Stripped: countdown / WebSocket / polarity / multi-user / reservations. Empty-state branch covers no-equipped-deck (link back to Game Kit; full Brief-redirect + Earthman-Backup fallback deferred to a follow-up sub-sprint). Minimal inline JS: click .sig-card → mark .sig-focused + set hidden card_id + enable SAVE SIGN; FLIP btn toggles .is-reversed + the hidden reversed input. Stage-card preview (name/qualifier population + keyword swap on FLIP) deferred — Sprint 4a follow-up will lift stage-card.js's populator into a non-room context ; **applet partial** (_applet-my-sign.html): renders user.significator's corner-rank + suit-icon + name_title if set; `.my-sign-applet-empty` "No sign chosen yet." otherwise. Header `<h2><a href="{% url 'billboard:my_sign' %}">Game Sign</a></h2>` links to the picker ; **helper refactor** (epic/models.py): extracted `_sig_unique_cards_for_deck(deck_variant)` from `_sig_unique_cards(room)`. New public `personal_sig_cards(user)` parallels `levity_sig_cards / gravity_sig_cards` but pulls from `user.equipped_deck` instead of `room.deck_variant`. Same Note-unlock filtering. No behavior change to existing room callers (3-line wrapper preserves the room signature) ; **TDD trail** — user called out mid-sprint that I'd skipped FTs; pivoted to FT-first. test_bill_my_sign.py (new, 3 FTs): T1 picker renders w. wordmark + target card present in grid; T2 click card → SAVE SIGN enables → POST persists → applet shows the card; T3 fresh user → applet renders empty-state. Initial reds — (a) setUp's `personal_sig_cards(user)` returned [] because StaticLiveServerTestCase → TransactionTestCase flushes migration-seeded DeckVariant + TarotCard between tests; fixed w. `serialized_rollback = True` on the test class (per [[feedback_transactiontestcase_flush]]); (b) h2 wordmark assertion against `MYSIGNIFICATOR` failed against the renamed "Game Sign" + the letter-splitter spreading chars across <span> children — switched to whitespace-stripped substring check `GAMESIGN`; (c) `.fan-corner-rank` text is CSS-hidden so Selenium returns "" — replaced corner-rank assertions w. data-card-id selectors (already-proven reliable from the parent .sig-card lookup) ; ITs (+12, in apps.billboard.tests.integrated.test_views): MySignViewTest (6 — login redirect, 200 + template, 16-card pile, save persists, invalid card_id → 403, GET save redirects); BillboardAppletMySignTest (3 — applet rendered, empty-state w/o sig, card+reversed class w. sig). PersonalSigCardsTest in apps.epic.tests.integrated.test_models (3 — happy path 16 cards, no-equipped-deck → [], schizo Note unlocks Major 1) ; pre-existing change picked up by the commit: my_sea.html branding "Game Sea" (user-modified mid-session; was "My Sea" in Sprint 3 — divergence captured in MEMORY.md follow-up) ; 1020 IT/UT green (+12) in 46s; 3 FTs green in 24s. Sprint 4a unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/) + Sprint 4c (FT helper for mocking the sig choice across other FTs)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 22:23:24 -04:00
|
|
|
|
# Global personal significator — chosen at /billboard/my-sig/ + persisted
|
|
|
|
|
|
# for reuse across My Sea draws (and eventually other contexts). Single
|
|
|
|
|
|
# FK; the orientation in `significator_reversed` (FLIP btn in the picker
|
|
|
|
|
|
# carousel) determines polarity at draw time.
|
|
|
|
|
|
significator = models.ForeignKey(
|
|
|
|
|
|
"epic.TarotCard", null=True, blank=True,
|
|
|
|
|
|
on_delete=models.SET_NULL, related_name="+",
|
|
|
|
|
|
)
|
|
|
|
|
|
significator_reversed = models.BooleanField(default=False)
|
fix: significator_reversed=polarity bug + Pattern B name-swap rendering + qualifier-aware applet faces + sticky PAID DRAW + cooldown anchor on User + stat-block polarity unification across Sig/Sea/Fan/applets
Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]).
**Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]].
**Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard).
**Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]].
**Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior).
**Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]].
**Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint):
- My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`).
- Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below.
- `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly.
**Memory recorded**:
1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT.
2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity.
3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table.
4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine.
**Files** (every uncommitted file folded in — session work + pre-existing modifications):
Models / migrations:
- `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField.
- `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema.
- `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18.
- `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`.
- `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed.
- `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema.
- `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator.
- `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema.
Views:
- `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`.
JS:
- `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face.
- `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card.
Templates:
- `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`.
- `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label.
- `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware).
- `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards.
- `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags.
SCSS:
- `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`.
- `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs.
- `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots.
Tests:
- `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`.
- `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback).
- `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end).
- `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:06:35 -04:00
|
|
|
|
# My Sea free-draw cooldown anchor — the timestamp of the user's most
|
|
|
|
|
|
# recent TOKENLESS first-card-draw of a 24h cycle. Set when the user
|
|
|
|
|
|
# creates a MySeaDraw row via the FREE DRAW path (button on the my-sea
|
|
|
|
|
|
# landing); PAID DRAW deliberately does NOT update it, so the next
|
|
|
|
|
|
# free draw is always anchored to the original free-draw moment, not
|
|
|
|
|
|
# the most recent paid one (user-spec 2026-05-23). Drives the Brief
|
|
|
|
|
|
# banner's next-free-draw timestamp + the landing-button state machine
|
|
|
|
|
|
# (FREE DRAW vs GATE VIEW vs PAID DRAW).
|
|
|
|
|
|
last_free_draw_at = models.DateTimeField(null=True, blank=True)
|
@taxman Debits & credits ledger + NVM-persistent FREE/PAID DRAW Briefs — TDD
User-spec 2026-05-26 for /gameboard/my-sea/. The transient "Free draw locked" Brief that re-appeared on every page load is replaced by a server-driven Brief whose NVM dismissal persists per-cycle, AND every spend now lands a permanent line on a new @taxman-authored "Debits & credits" Post (so the info goes somewhere instead of vanishing on dismiss). Same NVM-persistence treatment for the new PAID DRAW Brief.
Lyric:
- RESERVED_USERNAMES adds "taxman"; get_or_create_taxman() parallels get_or_create_adman() (username=taxman, email=taxman@earthmanrpg.local, unusable password, searchable=False).
- New nullable User.{free,paid}_draw_brief_dismissed_at DateTimeFields — anchor stamps for the NVM-persistence semantics. Cleared by my_sea_lock (free) / my_sea_paid_draw (paid) on each fresh spend so the new cycle re-opens the Brief surface.
- Migration 0014_brief_dismissal_fields adds the fields + RunPython seeds @taxman (mirror of 0003_seed_adman).
Billboard:
- Post.KIND_TAX_LEDGER + TAX_LEDGER_POST_TITLE = "Debits & credits"; Brief.KIND_TAX_LEDGER for routing.
- _delete_unsolicited_admin_post_lines extended via _SYSTEM_AUTHOR_POST_KINDS tuple — TAX_LEDGER joins NOTE_UNLOCK in the post_save guard that nukes any Line w.o. admin_solicited=True.
- Brief.to_banner_dict adds dismiss_url slot (empty by default; populated by the gameboard view for TAX_LEDGER briefs) + uses line.display_text instead of line.text so the prefix is stripped on the banner too.
- Line.display_text property — strips the leading "[iso-timestamp] " prefix that log_tax_debit bakes into TAX_LEDGER Lines (the prefix exists ONLY to satisfy unique_together = (post, text) on repeat-slug spends; the per-Brief + per-Line created_at slots already render the user-facing moment). Identity for non-tax Lines.
- view_post / delete_post / abandon_post guards extended to treat TAX_LEDGER like NOTE_UNLOCK (POST forbidden, can't delete, can't bye).
- Migration 0008_tax_ledger_kind registers the new choices on Post.kind + Brief.kind.
Billboard tax module (new apps/billboard/tax.py):
- TAX_DEBIT_TEMPLATES — canonical body text per slug, with FREE DRAW / PAID DRAW / GATE VIEW button-labels wrapped in .btn-pri-name spans:
- free_draw_locked → "Look!—my_sea.html [FREE DRAW] is locked. Next free draw available 24h from the production of this log."
- paid_draw_locked → "Look!—my_sea.html [PAID DRAW] is locked. Another may be unlocked by depositing a Token in [GATE VIEW]."
- log_tax_debit(user, slug) — get-or-creates the user's TAX_LEDGER Post, appends a timestamp-prefixed Line authored by @taxman w. admin_solicited=True, spawns a Brief. Returns (post, line, brief).
Gameboard:
- my_sea_lock first-card-of-cycle branch calls log_tax_debit(user, "free_draw_locked") + clears free_draw_brief_dismissed_at. Response now includes free_draw_brief_payload (Brief.to_banner_dict w. dismiss_url populated) so the picker IIFE can surface the new Brief in-place w.o. a page reload — same affordance the prior _showFreeDrawLockedBrief provided, w. server-authored copy + NVM-persistence.
- my_sea_paid_draw after paid_through_at stamp calls log_tax_debit(user, "paid_draw_locked") + clears paid_draw_brief_dismissed_at. Next-page-load surfaces the new Brief via the context payload.
- New my_sea_dismiss_free_draw_brief + my_sea_dismiss_paid_draw_brief POST endpoints stamp the matching User anchor field; return 204. URLs at /gameboard/my-sea/brief/{free,paid}-draw/dismiss.
- my_sea view's context computes {free,paid}_draw_brief_payload via the new _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url) helper — returns the latest TAX_LEDGER Brief's to_banner_dict IF (dismissal anchor is None OR anchor < brief.created_at). Slug discrimination via line__text__contains="FREE DRAW" / "PAID DRAW" (kept the Brief schema flat — only two markers today, non-overlapping wordings).
Frontend (apps/dashboard/static/apps/dashboard/note.js):
- Brief.showBanner NVM handler now fires a fire-and-forget POST to brief.dismiss_url (if present) before removing the banner. Persistent-NVM kinds (TAX_LEDGER) supply it; transient kinds leave the field empty + the handler no-ops to the existing dismiss-only behavior. CSRF token pulled from the csrftoken cookie.
SCSS (static_src/scss/_billboard.scss):
- .post-line--system .post-line-text .btn-pri-name — inline emphasis (color: --quaUser, font-weight: 700, font-style: normal) on canonical .btn-primary button labels referenced in @taxman ledger prose. User-spec 2026-05-26 mid-flight clarification: log surface only, not the actual buttons.
Templates:
- templates/apps/gameboard/my_sea.html: replaces the inline _showFreeDrawLockedBrief({{ next_free_draw_at|date:'c' }}) invocation w. two {% if *_brief_payload %} blocks that json_script the payload + dispatch via a new _showTaxBrief(payload, bannerClass) helper. _postLock updated to call _showFreeDrawLockedBrief(body.free_draw_brief_payload) so freshly-emitted Briefs surface in-place w.o. a reload (same affordance as before, w. server payload).
- templates/apps/billboard/post.html: readonly-textarea / system-author-styling / bud-panel-suppression branches all extended to cover post.kind == 'tax_ledger' (parallel to existing 'note_unlock' cases). Line-text rendering uses line.display_text (strips the iso prefix) + treats @taxman the same as @adman (allow HTML rendering for the system-author safe text — required so the .btn-pri-name spans aren't escaped).
Tests:
UTs (apps/billboard/tests/integrated/test_tax.py — 11 specs):
- log_tax_debit creates Post/Line/Brief w. correct kind + author + admin_solicited.
- Both slug templates produce expected text (assertions tolerant of inline .btn-pri-name span HTML).
- Two spends share one Post w. two distinct Lines (timestamp prefix keeps unique_together happy).
- Unknown slug raises KeyError.
- post_save guard nukes unsolicited Lines on TAX_LEDGER Posts; solicited Lines survive.
- "taxman" is reserved (case-insensitive); get_or_create_taxman idempotent.
ITs (apps/gameboard/tests/integrated/test_tax_briefs.py — 13 specs):
- my_sea_lock first-card creates TAX_LEDGER Post + Line + Brief; mid-cycle upserts do NOT emit extra debits; clears free_draw_brief_dismissed_at.
- my_sea_paid_draw commit creates a separate TAX_LEDGER entry; clears paid_draw_brief_dismissed_at.
- Dismiss endpoints stamp the matching User anchor; reject GET (405); require login (302).
- my_sea context: *_brief_payload is None until first spend; populated after; suppressed after NVM-dismiss; returns after cycle reset.
Existing ITs adjusted (apps/gameboard/tests/integrated/test_views.py):
- test_view_triggers_brief_banner_when_active_draw_exists + test_empty_hand_brief_banner_still_triggered + test_view_does_not_trigger_brief_banner_without_active_draw — assertions retargeted from window._showFreeDrawLockedBrief(" to id="id_free_draw_brief_payload" (the new json_script payload tag).
- test_brief_next_free_draw_at_uses_user_anchor_not_paid_row — switched from HTML-substring assertion against the rendered ISO (now absent from the page) to a direct response.context["next_free_draw_at"] comparison. Same underlying invariant; cleaner assertion shape.
FT (functional_tests/test_bill_post_debits_credits.py — 1 spec):
- After two seeded debits, /billboard/post/<uuid>/ renders the "Debits & credits" title, both Line bodies (FREE DRAW + PAID DRAW), @taxman attribution, readonly input w. "No response needed at this time" placeholder, AND verifies the "[iso] " prefix is stripped from display.
All 1340 IT+UT green; new FT green; existing FTs unaffected by these changes.
Pending follow-up (recorded for next sprint):
Per user 2026-05-26 in-flight ask: refactor @adman concerns into apps/billboard/ad.py (paralleling the new apps/billboard/tax.py) — extract Note.grant_if_new's billboard-side concerns (Post/Line/Brief creation, prose templates) out of apps/drama/models.py into the same shape log_tax_debit now follows. Notated for after this sprint lands.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 16:26:42 -04:00
|
|
|
|
# NVM-dismissal anchors for the FREE/PAID DRAW "locked" Briefs (user-spec
|
|
|
|
|
|
# 2026-05-26). When the user clicks NVM on either Brief, the corresponding
|
|
|
|
|
|
# timestamp gets stamped via the dismiss-brief POST endpoints; the my_sea
|
|
|
|
|
|
# view then suppresses the Brief on subsequent page loads as long as the
|
|
|
|
|
|
# dismissal is more recent than the cycle's anchor moment. `my_sea_lock`
|
|
|
|
|
|
# clears `free_draw_brief_dismissed_at` when a fresh FREE DRAW lands;
|
|
|
|
|
|
# `my_sea_paid_draw` clears `paid_draw_brief_dismissed_at` on commit —
|
|
|
|
|
|
# so each fresh spend re-opens the Brief surface for that cycle.
|
|
|
|
|
|
free_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
paid_draw_brief_dismissed_at = models.DateTimeField(null=True, blank=True)
|
my-sea voice: persist mute across in-sea nav/refresh + 3-min muted auto-disconnect; fix first-connect glow/mute race — TDD
MUTE PERSISTENCE (user-spec 2026-05-30) — a voice mute used to vanish on any
in-sea navigation/refresh (the mesh tears down + auto-rejoins unmuted). Now the
mute is stamped server-side + re-applied on rejoin, with a 3-min muted →
auto-disconnect window:
- `User.voice_muted_at` (timestamp, not a bare bool, so the 3-min window anchors
here) + migration. Per-user, not per-seat: the owner has no seat row, and a
user is in ≤1 voice room at a time, so this uniformly covers owner + visitor.
- POST `/voice/mute` {muted} sets/clears it (new voice app views.py + urls.py,
mounted at `voice/` in core/urls). my_sea + my_sea_visit pass the timestamp to
`#id_voice_btn` as `data-voice-muted-at`.
- voice-mesh.js gains `setMuted(m)` (set vs. toggleMute's flip), honoured by
join's post-getUserMedia `_applyMute`. burger-btn.js: a mute toggle POSTs the
state + arms a client timer; the auto-rejoin re-applies the persisted mute +
re-arms the timer from the stored timestamp (so the 3-min spans navigations,
not resets); an elapsed window on rejoin auto-disconnects instead of rejoining;
a fresh manual join clears any stale mute. On timeout: leave voice + clear.
FIRST-CONNECT GLOW/MUTE RACE (user-reported) — `setOnStateChange` pushes the
current state immediately on subscribe, and voice-glow.js often subscribes
MID-JOIN (getUserMedia pending → inCall=false). Its `setVoiceState` only ever
DELETED `voice.dataset.inCall` (never re-set it) — wiping the join-vs-mute flag
burger-btn.js had just set, so the next click re-joined instead of muting (which
also dropped the peer + killed the equalizer). Two fixes:
- voice-glow keeps `dataset.inCall` SYMMETRIC (set on true, delete on false), so
the mid-join false is restored once the stream resolves → mute works on first
connect.
- voice-glow subscribes reliably on AUTO-REJOIN too (no click to trigger its
poll): voice-mesh.js dispatches `voiceroom:ready` on singleton creation +
voice-glow listens, so the glow is mesh-driven (peer-count equalizer) after a
refresh, not just the in-call-class fallback.
Coverage:
- ITs: VoiceMuteViewTest (login/405/invalid-json guards, stamp on true, clear on
false, re-mute restamps, missing-key=false). voice+lyric 164 green.
- Jasmine: BurgerSpec mute persistence (muteRemainingMs window, rejoin re-mute,
expired-window auto-disconnect, toggle-persists + 3-min fires, manual-join
clears); VoiceGlowSpec dataset.inCall sync (sets on in-call, clears on not,
restores after a mid-join false→true). All green.
- Live multi-party voice (mic/2-device) left to manual verification.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 01:41:30 -04:00
|
|
|
|
# My-Sea voice mute persistence (user-spec 2026-05-30). The user's CURRENT
|
|
|
|
|
|
# voice mute state, stored as a TIMESTAMP (not a bare bool) so the 3-min
|
|
|
|
|
|
# "muted too long → auto-disconnect" window anchors here. Null = not muted.
|
|
|
|
|
|
# Stamped when the user mutes in the mesh; the voice auto-rejoin reads it so
|
|
|
|
|
|
# the mute carries across in-sea navigation/refresh; cleared on unmute, on a
|
|
|
|
|
|
# fresh manual join, + when the 3-min window elapses (client leaves voice).
|
|
|
|
|
|
# Per-user (not per-seat): the owner has no seat row, and a user is in at
|
|
|
|
|
|
# most one voice room at a time, so this uniformly covers owner + visitor.
|
|
|
|
|
|
voice_muted_at = models.DateTimeField(null=True, blank=True)
|
2026-04-02 15:22:04 -04:00
|
|
|
|
ap_public_key = models.TextField(blank=True, default="")
|
|
|
|
|
|
ap_private_key = models.TextField(blank=True, default="")
|
2026-03-02 13:57:03 -05:00
|
|
|
|
|
2026-04-16 03:03:19 -04:00
|
|
|
|
# Personal natal chart (My Sky) — independent of any game room/character
|
|
|
|
|
|
sky_birth_dt = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
sky_birth_lat = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
|
|
|
|
|
|
sky_birth_lon = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
|
|
|
|
|
|
sky_birth_place = models.CharField(max_length=255, blank=True)
|
2026-04-21 21:54:34 -04:00
|
|
|
|
sky_birth_tz = models.CharField(max_length=64, blank=True)
|
2026-04-16 03:03:19 -04:00
|
|
|
|
sky_house_system = models.CharField(max_length=1, blank=True, default="O")
|
|
|
|
|
|
sky_chart_data = models.JSONField(null=True, blank=True)
|
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
is_staff = models.BooleanField(default=False)
|
|
|
|
|
|
is_superuser = models.BooleanField(default=False)
|
2026-01-30 15:04:47 -05:00
|
|
|
|
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
|
pronouns = models.CharField(
|
|
|
|
|
|
max_length=16, choices=PRONOUN_CHOICES, default="pluralism",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
objects = UserManager()
|
2026-01-30 15:04:47 -05:00
|
|
|
|
REQUIRED_FIELDS = []
|
|
|
|
|
|
USERNAME_FIELD = "email"
|
2026-02-19 20:31:29 -05:00
|
|
|
|
|
fix: significator_reversed=polarity bug + Pattern B name-swap rendering + qualifier-aware applet faces + sticky PAID DRAW + cooldown anchor on User + stat-block polarity unification across Sig/Sea/Fan/applets
Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]).
**Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]].
**Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard).
**Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]].
**Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior).
**Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]].
**Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint):
- My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`).
- Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below.
- `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly.
**Memory recorded**:
1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT.
2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity.
3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table.
4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine.
**Files** (every uncommitted file folded in — session work + pre-existing modifications):
Models / migrations:
- `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField.
- `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema.
- `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18.
- `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`.
- `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed.
- `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema.
- `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator.
- `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema.
Views:
- `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`.
JS:
- `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face.
- `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card.
Templates:
- `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`.
- `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label.
- `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware).
- `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards.
- `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags.
SCSS:
- `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`.
- `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs.
- `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots.
Tests:
- `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`.
- `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback).
- `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end).
- `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:06:35 -04:00
|
|
|
|
# ── My Sea free-draw cooldown helpers ────────────────────────────────
|
|
|
|
|
|
# Pair w. `last_free_draw_at` above. The cooldown anchors to the FREE
|
|
|
|
|
|
# DRAW moment (NOT to any subsequent paid draws), so the Brief banner
|
|
|
|
|
|
# surfaces "next free draw at" relative to the user's actual cycle
|
|
|
|
|
|
# start. PAID DRAWs commit their tokens against this same cooldown
|
|
|
|
|
|
# window — they don't reset it.
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def sig_face(self):
|
|
|
|
|
|
"""Rendering payload for the saved sig in `_applet-my-sign.html`.
|
|
|
|
|
|
`significator_reversed` is the POLARITY axis (FLIP — True ↔ levity,
|
|
|
|
|
|
per [[feedback-significator-reversed-is-polarity]]); the SPIN /
|
|
|
|
|
|
orientation axis is never persisted, so the saved sig is always
|
|
|
|
|
|
rendered upright in its polarity (reversed=False to `applet_face`).
|
|
|
|
|
|
Returns `None` when no sig is saved."""
|
|
|
|
|
|
if self.significator is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
polarity = 'levity' if self.significator_reversed else 'gravity'
|
|
|
|
|
|
return self.significator.applet_face(polarity, reversed=False)
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def free_draw_cooldown_active(self):
|
|
|
|
|
|
"""True iff the user is currently inside the 24h cooldown window
|
|
|
|
|
|
triggered by their last tokenless free draw. False for fresh users
|
|
|
|
|
|
(never free-drew) and for users whose cooldown has elapsed."""
|
|
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
if self.last_free_draw_at is None:
|
|
|
|
|
|
return False
|
|
|
|
|
|
return self.last_free_draw_at + timedelta(hours=24) > timezone.now()
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def next_free_draw_at(self):
|
|
|
|
|
|
"""Datetime when the user's next free draw becomes available
|
|
|
|
|
|
(`last_free_draw_at + 24h`). Returns None if the user has never
|
|
|
|
|
|
free-drawn."""
|
|
|
|
|
|
from datetime import timedelta
|
|
|
|
|
|
if self.last_free_draw_at is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return self.last_free_draw_at + timedelta(hours=24)
|
|
|
|
|
|
|
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
|
|
|
|
@property
|
|
|
|
|
|
def active_title_display(self):
|
|
|
|
|
|
"""Render-ready string for "{username} the {title}" attributions —
|
2026-05-18 21:42:58 -04:00
|
|
|
|
returns the donned Note's `display_name`, or 'Earthman' when no
|
|
|
|
|
|
Note is donned. Uses `display_name` (not `display_title`) so the
|
|
|
|
|
|
Baltimorean rename — `display_title="Ard!"` (navbar DON greeting
|
|
|
|
|
|
flair only) vs. `display_name="Baltimorean"` (everywhere else) —
|
|
|
|
|
|
propagates to inline attributions like `.post-attribution`. For
|
|
|
|
|
|
non-overridden slugs the two are equal so this is a no-op."""
|
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
|
|
|
|
if self.active_title_id:
|
2026-05-18 21:42:58 -04:00
|
|
|
|
return self.active_title.display_name
|
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
|
|
|
|
return "Earthman"
|
|
|
|
|
|
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
|
@property
|
|
|
|
|
|
def pronoun_subj(self):
|
|
|
|
|
|
return resolve_pronouns(self.pronouns)[0]
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def pronoun_obj(self):
|
|
|
|
|
|
return resolve_pronouns(self.pronouns)[1]
|
|
|
|
|
|
|
|
|
|
|
|
@property
|
|
|
|
|
|
def pronoun_poss(self):
|
|
|
|
|
|
return resolve_pronouns(self.pronouns)[2]
|
|
|
|
|
|
|
2026-04-02 15:22:04 -04:00
|
|
|
|
def ensure_keypair(self):
|
|
|
|
|
|
"""Generate and persist an RSA-2048 keypair if not already set."""
|
|
|
|
|
|
if self.ap_public_key:
|
|
|
|
|
|
return
|
|
|
|
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
|
|
|
|
from cryptography.hazmat.primitives import serialization
|
|
|
|
|
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
|
|
|
|
self.ap_public_key = private_key.public_key().public_bytes(
|
|
|
|
|
|
serialization.Encoding.PEM,
|
|
|
|
|
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
|
|
|
|
).decode()
|
|
|
|
|
|
self.ap_private_key = private_key.private_bytes(
|
|
|
|
|
|
serialization.Encoding.PEM,
|
|
|
|
|
|
serialization.PrivateFormat.PKCS8,
|
|
|
|
|
|
serialization.NoEncryption(),
|
|
|
|
|
|
).decode()
|
|
|
|
|
|
self.save(update_fields=["ap_public_key", "ap_private_key"])
|
|
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
def has_perm(self, perm, obj=None):
|
|
|
|
|
|
return self.is_superuser
|
2026-01-30 21:33:30 -05:00
|
|
|
|
|
2026-02-19 20:31:29 -05:00
|
|
|
|
def has_module_perms(self, app_label):
|
|
|
|
|
|
return self.is_superuser
|
2026-03-08 15:14:41 -04:00
|
|
|
|
|
|
|
|
|
|
class Wallet(models.Model):
|
|
|
|
|
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="wallet")
|
|
|
|
|
|
writs = models.IntegerField(default=0)
|
|
|
|
|
|
esteem = models.IntegerField(default=0)
|
|
|
|
|
|
|
2026-03-10 14:11:53 -04:00
|
|
|
|
def tooltip_name(self):
|
|
|
|
|
|
return "Wallet"
|
|
|
|
|
|
|
|
|
|
|
|
def tooltip_description(self):
|
|
|
|
|
|
return f"{self.writs} writs · {self.esteem} esteem"
|
|
|
|
|
|
|
|
|
|
|
|
def tooltip_shoptalk(self):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def tooltip_expiry(self):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def tooltip_text(self):
|
|
|
|
|
|
return f"{self.tooltip_name()}: {self.tooltip_description()}"
|
|
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
|
class Token(models.Model):
|
|
|
|
|
|
COIN = "coin"
|
|
|
|
|
|
FREE = "Free"
|
|
|
|
|
|
TITHE = "tithe"
|
2026-03-14 22:00:16 -04:00
|
|
|
|
PASS = "pass"
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
|
BAND = "band"
|
2026-03-16 00:07:52 -04:00
|
|
|
|
CARTE = "carte"
|
2026-03-08 15:14:41 -04:00
|
|
|
|
TOKEN_TYPE_CHOICES = [
|
|
|
|
|
|
(COIN, "Coin-on-a-String"),
|
|
|
|
|
|
(FREE, "Free Token"),
|
|
|
|
|
|
(TITHE, "Tithe Token"),
|
2026-03-14 22:00:16 -04:00
|
|
|
|
(PASS, "Backstage Pass"),
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
|
(BAND, "Wristband"),
|
2026-03-16 00:07:52 -04:00
|
|
|
|
(CARTE, "Carte Blanche"),
|
2026-03-08 15:14:41 -04:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
|
|
|
|
|
|
token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES)
|
|
|
|
|
|
expires_at = models.DateTimeField(null=True, blank=True)
|
2026-03-13 17:31:52 -04:00
|
|
|
|
current_room = models.ForeignKey(
|
|
|
|
|
|
"epic.Room", null=True, blank=True,
|
|
|
|
|
|
on_delete=models.SET_NULL, related_name="coin_tokens"
|
|
|
|
|
|
)
|
|
|
|
|
|
next_ready_at = models.DateTimeField(null=True, blank=True)
|
2026-03-16 00:07:52 -04:00
|
|
|
|
slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True)
|
2026-03-08 15:14:41 -04:00
|
|
|
|
|
2026-05-21 00:35:55 -04:00
|
|
|
|
def clean(self):
|
|
|
|
|
|
# PASS is admin-only — game-side surfaces (gameboard, game-kit, gate
|
|
|
|
|
|
# picker) all filter PASS behind user.is_staff, so a non-staff PASS
|
|
|
|
|
|
# row is invisible/unusable and just clutters the wallet. A non-admin
|
|
|
|
|
|
# variant ("Boost Pass" or similar) will land as a distinct token_type
|
|
|
|
|
|
# later — keep the rule strict here so the two never blur.
|
|
|
|
|
|
super().clean()
|
|
|
|
|
|
if self.token_type == self.PASS and self.user_id and not self.user.is_staff:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
{"token_type": "PASS is admin-only — staff users only."}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
|
|
|
|
|
if self.token_type == self.PASS and self.user_id and not self.user.is_staff:
|
|
|
|
|
|
raise ValidationError(
|
|
|
|
|
|
{"token_type": "PASS is admin-only — staff users only."}
|
|
|
|
|
|
)
|
|
|
|
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
|
2026-03-09 23:48:20 -04:00
|
|
|
|
def tooltip_name(self):
|
2026-03-08 15:14:41 -04:00
|
|
|
|
return self.get_token_type_display()
|
|
|
|
|
|
|
2026-03-09 23:48:20 -04:00
|
|
|
|
def tooltip_description(self):
|
2026-03-15 17:54:58 -04:00
|
|
|
|
if self.token_type in (self.COIN, self.FREE):
|
2026-03-09 23:48:20 -04:00
|
|
|
|
return "Admit 1 Entry"
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
|
if self.token_type in (self.PASS, self.BAND):
|
2026-03-15 17:54:58 -04:00
|
|
|
|
return "Admit All Entry"
|
2026-03-11 00:58:24 -04:00
|
|
|
|
if self.token_type == self.TITHE:
|
|
|
|
|
|
return "+ Writ bonus"
|
2026-03-16 00:07:52 -04:00
|
|
|
|
if self.token_type == self.CARTE:
|
|
|
|
|
|
return "Admit up to +6"
|
2026-03-09 23:48:20 -04:00
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def tooltip_expiry(self):
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
|
if self.token_type in (self.COIN, self.PASS, self.BAND, self.CARTE):
|
2026-03-14 22:00:16 -04:00
|
|
|
|
if self.token_type == self.COIN and self.next_ready_at:
|
2026-03-13 17:31:52 -04:00
|
|
|
|
return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
|
2026-03-09 23:48:20 -04:00
|
|
|
|
return "no expiry"
|
|
|
|
|
|
if self.expires_at:
|
|
|
|
|
|
return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
|
|
|
|
|
|
return ""
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
|
|
|
|
|
def tooltip_room_html(self):
|
|
|
|
|
|
if not self.current_room_id:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
url = reverse("epic:gatekeeper", kwargs={"room_id": self.current_room_id})
|
|
|
|
|
|
return f'<a href="{url}">{self.current_room.name}</a>'
|
2026-03-09 23:48:20 -04:00
|
|
|
|
|
|
|
|
|
|
def tooltip_shoptalk(self):
|
|
|
|
|
|
if self.token_type == self.COIN:
|
|
|
|
|
|
return "\u2026and another after that, and another after that\u2026"
|
2026-03-16 00:07:52 -04:00
|
|
|
|
if self.token_type == self.FREE:
|
|
|
|
|
|
return "a spot of good fortune"
|
2026-03-15 01:17:09 -04:00
|
|
|
|
if self.token_type == self.PASS:
|
2026-03-15 01:46:11 -04:00
|
|
|
|
return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
|
if self.token_type == self.BAND:
|
|
|
|
|
|
return "Unlimited free entry (BYOB)"
|
2026-03-16 00:07:52 -04:00
|
|
|
|
if self.token_type == self.CARTE:
|
|
|
|
|
|
return "No, I\u2019m afraid we\u2019ll be taking over from here."
|
2026-03-09 23:48:20 -04:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def tooltip_text(self):
|
|
|
|
|
|
text = f"{self.tooltip_name()}: {self.tooltip_description()}"
|
|
|
|
|
|
if self.tooltip_shoptalk():
|
|
|
|
|
|
text += f" ({self.tooltip_shoptalk()})"
|
|
|
|
|
|
text += f" \u2014 {self.tooltip_expiry()}"
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
2026-03-09 01:07:16 -04:00
|
|
|
|
class PaymentMethod(models.Model):
|
|
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
|
|
|
|
|
|
stripe_pm_id = models.CharField(max_length=255)
|
|
|
|
|
|
last4 = models.CharField(max_length=4)
|
|
|
|
|
|
brand = models.CharField(max_length=32)
|
|
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
|
return f"{self.brand} ....{self.last4}"
|
feat: ShopItem + Purchase models + seed `tithe-1` / `tithe-5` / `band-1` + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. `ShopItem` is the admin-managed catalog: `slug`, `name`, `description`, `icon` (FA class), `badge_text` (eg "×5"), `price_cents`, `granted_token_type` (any Token type), `granted_count`, `granted_writs` (default 0), `max_owned` (nullable; BAND=1), `display_order`, `active`. `is_available_for(user)` enforces `max_owned` by comparing user's owned-count of the granted token type. `price_display()` renders cents → "$1" / "$4.20" for tooltip prose. `Purchase` is the per-tx audit trail: `user` + `shop_item` + `stripe_payment_intent_id` (unique) + `status` (PENDING/SUCCEEDED/FAILED/REFUNDED) + `amount_cents` snapshot + `granted_writs` snapshot + `granted_token_ids` JSONField (PKs of minted tokens) + `created_at` + `succeeded_at`. `fulfill()` is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync `/shop/confirm` racing each other can't double-mint. Schema migration `lyric/0008_shopitem_purchase` autogenerated. Seed migration `lyric/0009_seed_shop_items` populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration `applets/0011_seed_wallet_shop_applet` adds the `wallet-shop` Applet (context=wallet, 12 cols × 3 rows). Stub `_applet-wallet-shop.html` lands w. just `<section id="id_wallet_shop">` + `<h2>Shop</h2>` — `_applets.html`'s auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in `test_shop_models.py`: `ShopItemModelTest` (9 cases — minimal create, defaults for granted_writs / max_owned / active, `is_available_for` w/ + w/o max_owned cap, str repr), `PurchaseModelTest` (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), `SeededShopCatalogTest` (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), `SeededWalletShopAppletTest` (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:30:59 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class ShopItem(models.Model):
|
|
|
|
|
|
"""A purchasable bundle in the wallet's Shop applet — admin-managed
|
|
|
|
|
|
catalog. Each row defines (price → granted Tokens + writs); the
|
|
|
|
|
|
`Purchase.fulfill()` flow mints `granted_count` tokens of
|
|
|
|
|
|
`granted_token_type` + bumps `Wallet.writs` by `granted_writs`.
|
|
|
|
|
|
|
|
|
|
|
|
See [[project-wallet-shop-expansion]] for the broader design + the
|
|
|
|
|
|
3 starting catalog items (tithe-1, tithe-5, band-1) seeded in
|
|
|
|
|
|
`lyric/0009_seed_shop_items`."""
|
|
|
|
|
|
|
|
|
|
|
|
slug = models.SlugField(unique=True)
|
|
|
|
|
|
name = models.CharField(max_length=100)
|
|
|
|
|
|
description = models.TextField(blank=True, default="")
|
feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits 8e476f5 → d28cf7b). **Microtooltip extraction**: `.tt-microbutton-portal` (Chunk 4's wrap-inside-`.tt`) replaced w. a sibling `.tt-micro` div on each `.shop-tile`. `wallet.js`'s `initWalletTooltips` clones BOTH into separate portals on hover — `.tt` → `#id_tooltip_portal` (main card), `.tt-micro` → `#id_mini_tooltip_portal` (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: `WalletTooltips.pin()` / `.unpin()` exposed on window; `wallet-shop.js`'s BUY click calls `pin()` before `showGuard()` + both `onConfirm` / `onDismiss` callbacks call `unpin()` → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new `Applet.display_order` field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets `wallet-shop.display_order=10` so Shop renders atop Balances/Tokens/Payment. `applet_context()` updated to `.order_by("display_order", "pk")`. New `WalletAppletOrderTest` (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot `.tt-title` / `.tt-description` / `.tt-shoptalk` / `.tt-expiry` classes as the Tokens row. New `ShopItem.shoptalk` field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New `ShopItem.tooltip_expiry()` method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: `.tt-micro` hidden in source DOM (portal-only); `#id_mini_tooltip_portal` mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows BUY-btn label to wrap onto multiple lines (`white-space: normal` on `.tt-buy-btn`); `.tt-already-owned` styled w. `--secUser` italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: `lyric/0010_repricing_tithe_writs` (writs + description), `lyric/0011_shopitem_shoptalk` (schema), `lyric/0012_seed_shop_shoptalk` (band split), `applets/0012_applet_display_order` (schema), `applets/0013_wallet_shop_display_order` (Shop atop). All idempotent. **TDD** — 5 new ITs across `test_shop_models.py` (`shoptalk` default + per-item assertions, `tooltip_expiry` method, updated tithe writs values, `WalletAppletOrderTest`), 1 new FT (`test_shop_buy_guard_portal_pins_item_tooltip` — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to `.tt-micro` (no `.tt-buy-btn` present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line `{# #}` comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) `spyOn(window, 'fetch')` Jasmine double-spy collision (cf trapped previously); (c) async pollution where `afterEach` restores `window.Stripe=undefined` before `_doBuy`'s continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:21:10 -04:00
|
|
|
|
# `shoptalk` is the italic flavor line that mirrors `Token.tooltip_shoptalk` —
|
|
|
|
|
|
# rendered via the `.tt-shoptalk` SCSS class (DRY w. the wallet's Token row).
|
|
|
|
|
|
# Blank → the `{% if item.shoptalk %}` slot in the template is skipped.
|
|
|
|
|
|
shoptalk = models.CharField(max_length=200, blank=True, default="")
|
feat: ShopItem + Purchase models + seed `tithe-1` / `tithe-5` / `band-1` + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. `ShopItem` is the admin-managed catalog: `slug`, `name`, `description`, `icon` (FA class), `badge_text` (eg "×5"), `price_cents`, `granted_token_type` (any Token type), `granted_count`, `granted_writs` (default 0), `max_owned` (nullable; BAND=1), `display_order`, `active`. `is_available_for(user)` enforces `max_owned` by comparing user's owned-count of the granted token type. `price_display()` renders cents → "$1" / "$4.20" for tooltip prose. `Purchase` is the per-tx audit trail: `user` + `shop_item` + `stripe_payment_intent_id` (unique) + `status` (PENDING/SUCCEEDED/FAILED/REFUNDED) + `amount_cents` snapshot + `granted_writs` snapshot + `granted_token_ids` JSONField (PKs of minted tokens) + `created_at` + `succeeded_at`. `fulfill()` is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync `/shop/confirm` racing each other can't double-mint. Schema migration `lyric/0008_shopitem_purchase` autogenerated. Seed migration `lyric/0009_seed_shop_items` populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration `applets/0011_seed_wallet_shop_applet` adds the `wallet-shop` Applet (context=wallet, 12 cols × 3 rows). Stub `_applet-wallet-shop.html` lands w. just `<section id="id_wallet_shop">` + `<h2>Shop</h2>` — `_applets.html`'s auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in `test_shop_models.py`: `ShopItemModelTest` (9 cases — minimal create, defaults for granted_writs / max_owned / active, `is_available_for` w/ + w/o max_owned cap, str repr), `PurchaseModelTest` (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), `SeededShopCatalogTest` (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), `SeededWalletShopAppletTest` (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:30:59 -04:00
|
|
|
|
icon = models.CharField(max_length=50) # FA icon class (eg "fa-piggy-bank")
|
|
|
|
|
|
badge_text = models.CharField(max_length=8, blank=True, default="") # eg "×5"; "" = no badge
|
|
|
|
|
|
price_cents = models.PositiveIntegerField()
|
|
|
|
|
|
granted_token_type = models.CharField(
|
|
|
|
|
|
max_length=8, choices=Token.TOKEN_TYPE_CHOICES,
|
|
|
|
|
|
)
|
|
|
|
|
|
granted_count = models.PositiveSmallIntegerField(default=1)
|
|
|
|
|
|
granted_writs = models.PositiveIntegerField(default=0)
|
|
|
|
|
|
# `max_owned=None` → unlimited stock per user. `max_owned=1` → BAND-style
|
|
|
|
|
|
# "you can only have one of these" — the shop UI disables BUY w. an
|
|
|
|
|
|
# "Already owned" microtooltip when the user's owned-count of the granted
|
|
|
|
|
|
# token type has reached this cap.
|
|
|
|
|
|
max_owned = models.PositiveSmallIntegerField(null=True, blank=True)
|
|
|
|
|
|
display_order = models.PositiveSmallIntegerField(default=100)
|
|
|
|
|
|
active = models.BooleanField(default=True)
|
|
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
|
ordering = ["display_order", "slug"]
|
|
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
|
return self.name
|
|
|
|
|
|
|
|
|
|
|
|
def is_available_for(self, user):
|
|
|
|
|
|
"""True iff the user can purchase another of this item right now.
|
|
|
|
|
|
Honors `max_owned` (compares to user's owned-count of the granted
|
|
|
|
|
|
token type). Items w. `max_owned=None` are always available."""
|
|
|
|
|
|
if self.max_owned is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
owned = user.tokens.filter(token_type=self.granted_token_type).count()
|
|
|
|
|
|
return owned < self.max_owned
|
|
|
|
|
|
|
|
|
|
|
|
def price_display(self):
|
|
|
|
|
|
"""Render-ready dollar string for tooltips. Cents trimmed for whole
|
|
|
|
|
|
dollars; otherwise two decimals."""
|
|
|
|
|
|
dollars = self.price_cents / 100
|
|
|
|
|
|
if dollars == int(dollars):
|
|
|
|
|
|
return f"${int(dollars)}"
|
|
|
|
|
|
return f"${dollars:.2f}"
|
|
|
|
|
|
|
feat: wallet Shop polish — microtooltip extraction, Shop-first ordering, DRY tooltip styling, writs rebalance, "no expiry" on all items. Visual-pass tweaks landing atop the 5-chunk Shop rollout (commits 8e476f5 → d28cf7b). **Microtooltip extraction**: `.tt-microbutton-portal` (Chunk 4's wrap-inside-`.tt`) replaced w. a sibling `.tt-micro` div on each `.shop-tile`. `wallet.js`'s `initWalletTooltips` clones BOTH into separate portals on hover — `.tt` → `#id_tooltip_portal` (main card), `.tt-micro` → `#id_mini_tooltip_portal` (small italic pill at bottom-right of main, mirroring Game Kit's Equipped/Unequipped/In-Use mini portal). Hover persistence covers both portals + the source tile w. a 200ms grace timer cancelled by mouseenter on any of the 3 zones. Capped items (BAND-owned) render NO btn at all — just "Already owned" microtext (mirrors Game Kit's status-only "Equipped" pill rather than the disabled-× pattern that lived in Chunk 4). **Tooltip-pin on guard open**: `WalletTooltips.pin()` / `.unpin()` exposed on window; `wallet-shop.js`'s BUY click calls `pin()` before `showGuard()` + both `onConfirm` / `onDismiss` callbacks call `unpin()` → the item tooltip stays visible behind the guard's "Buy {name} for ${price}?" prompt instead of orphaning. **Shop-first applet ordering**: new `Applet.display_order` field (default 100, lower = earlier; PK tie-break preserves legacy insertion-order for the existing 3 applets); seed migration sets `wallet-shop.display_order=10` so Shop renders atop Balances/Tokens/Payment. `applet_context()` updated to `.order_by("display_order", "pk")`. New `WalletAppletOrderTest` (2 ITs) pins Shop-first DOM order + view-context list. **DRY tooltip styling**: shop tooltip now uses the same 4-slot `.tt-title` / `.tt-description` / `.tt-shoptalk` / `.tt-expiry` classes as the Tokens row. New `ShopItem.shoptalk` field for the italic flavor line (band-1 = "Unlimited free entry (BYOB)" split out of description; tithes blank). New `ShopItem.tooltip_expiry()` method returns "no expiry" — eternal-stock convention (all current items; seasonal listings could override later). **Writs rebalance**: locked 2026-05-22 — tithe-1 144→12 writs, tithe-5 750→60 writs. Description text updated in lockstep ("1 Tithe Token + 12 Writs" / "5 Tithe Tokens + 60 Writs"). **Badge tweak**: ×N badge shrunk 2rem → 1.5rem + nudged further off-tile (top: -0.7rem, right: -1rem) so most of the underlying icon stays visible. **SCSS**: `.tt-micro` hidden in source DOM (portal-only); `#id_mini_tooltip_portal` mostly mirrors gameboard's mini at `_gameboard.scss:140` but allows BUY-btn label to wrap onto multiple lines (`white-space: normal` on `.tt-buy-btn`); `.tt-already-owned` styled w. `--secUser` italic at 0.85rem to match Game Kit pills. **Migrations** — 5 new: `lyric/0010_repricing_tithe_writs` (writs + description), `lyric/0011_shopitem_shoptalk` (schema), `lyric/0012_seed_shop_shoptalk` (band split), `applets/0012_applet_display_order` (schema), `applets/0013_wallet_shop_display_order` (Shop atop). All idempotent. **TDD** — 5 new ITs across `test_shop_models.py` (`shoptalk` default + per-item assertions, `tooltip_expiry` method, updated tithe writs values, `WalletAppletOrderTest`), 1 new FT (`test_shop_buy_guard_portal_pins_item_tooltip` — programmatically dispatches mouseenter/mouseleave to exercise the pin/unpin race), 3 new Jasmine specs (T6 pin-on-click, T7 unpin-on-confirm, T8 unpin-on-dismiss). Existing FT band-owned assertion switched to `.tt-micro` (no `.tt-buy-btn` present), Jasmine T2 rewritten to assert no btn renders. **3 traps caught** mid-build: (a) multi-line `{# #}` comment leaked into DOM again (cf [[feedback-django-comments-single-line-only]]) — pinned the trap; (b) `spyOn(window, 'fetch')` Jasmine double-spy collision (cf trapped previously); (c) async pollution where `afterEach` restores `window.Stripe=undefined` before `_doBuy`'s continuation hits it — fixed by per-test never-resolving fetch mock. 1211 IT/UT + 9 wallet FTs green; Jasmine SpecRunner verified visually (FT hangs Selenium-side on spec count). Pipeline will sweep all FTs
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 02:21:10 -04:00
|
|
|
|
def tooltip_expiry(self):
|
|
|
|
|
|
"""All shop items are eternal stock (no time-bound listings yet) so
|
|
|
|
|
|
the tooltip's `.tt-expiry` slot always shows 'no expiry' — same
|
|
|
|
|
|
red-callout styling as PASS/BAND/CARTE token tooltips. If a future
|
|
|
|
|
|
seasonal item needs a real expiry, override on the row + return
|
|
|
|
|
|
the formatted string here."""
|
|
|
|
|
|
return "no expiry"
|
|
|
|
|
|
|
feat: ShopItem + Purchase models + seed `tithe-1` / `tithe-5` / `band-1` + wallet-shop Applet — Chunk 2 of [[project-wallet-shop-expansion]]. `ShopItem` is the admin-managed catalog: `slug`, `name`, `description`, `icon` (FA class), `badge_text` (eg "×5"), `price_cents`, `granted_token_type` (any Token type), `granted_count`, `granted_writs` (default 0), `max_owned` (nullable; BAND=1), `display_order`, `active`. `is_available_for(user)` enforces `max_owned` by comparing user's owned-count of the granted token type. `price_display()` renders cents → "$1" / "$4.20" for tooltip prose. `Purchase` is the per-tx audit trail: `user` + `shop_item` + `stripe_payment_intent_id` (unique) + `status` (PENDING/SUCCEEDED/FAILED/REFUNDED) + `amount_cents` snapshot + `granted_writs` snapshot + `granted_token_ids` JSONField (PKs of minted tokens) + `created_at` + `succeeded_at`. `fulfill()` is idempotent — short-circuits if status==SUCCEEDED + refuses non-PENDING rows so a webhook + sync `/shop/confirm` racing each other can't double-mint. Schema migration `lyric/0008_shopitem_purchase` autogenerated. Seed migration `lyric/0009_seed_shop_items` populates the 3 starting items per locked decisions: tithe-1 ($1 → 1 TITHE + 144 writs, no cap, order=10); tithe-5 ($4 → 5 TITHE + 750 writs, no cap, badge "×5", order=20); band-1 ($20 → 1 BAND + 0 writs, max_owned=1, order=30). Applet migration `applets/0011_seed_wallet_shop_applet` adds the `wallet-shop` Applet (context=wallet, 12 cols × 3 rows). Stub `_applet-wallet-shop.html` lands w. just `<section id="id_wallet_shop">` + `<h2>Shop</h2>` — `_applets.html`'s auto-include-by-slug pattern would 500 the wallet page on TemplateDoesNotExist otherwise (caught mid-Chunk-2 by the full app suite). Chunk 4 fills in the shop-tile grid + BUY-ITEM microtooltip + Stripe.js wiring. TDD — 22 ITs in `test_shop_models.py`: `ShopItemModelTest` (9 cases — minimal create, defaults for granted_writs / max_owned / active, `is_available_for` w/ + w/o max_owned cap, str repr), `PurchaseModelTest` (8 cases — minimal create, PI ID uniqueness constraint, fulfill mints tokens + grants writs + marks SUCCEEDED + records granted_token_ids + is idempotent on re-fire + creates N tokens for bundle), `SeededShopCatalogTest` (4 cases pin tithe-1 / tithe-5 / band-1 row shapes + display_order ascending), `SeededWalletShopAppletTest` (1 case pins Applet seeded). 1191 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:30:59 -04:00
|
|
|
|
|
|
|
|
|
|
class Purchase(models.Model):
|
|
|
|
|
|
"""Audit-trail row for one shop transaction. Created at PENDING on
|
|
|
|
|
|
Stripe PaymentIntent creation, advanced to SUCCEEDED via `fulfill()`
|
|
|
|
|
|
(called from EITHER the synchronous `/shop/confirm` view OR the
|
|
|
|
|
|
`/stripe/webhook` handler — whichever wins). `fulfill()` is idempotent
|
|
|
|
|
|
so the race is harmless.
|
|
|
|
|
|
|
|
|
|
|
|
`granted_token_ids` snapshots the PKs of every Token row this purchase
|
|
|
|
|
|
minted so we can audit / refund / rebuild later without re-deriving
|
|
|
|
|
|
from `created_at`."""
|
|
|
|
|
|
|
|
|
|
|
|
PENDING = "PENDING"
|
|
|
|
|
|
SUCCEEDED = "SUCCEEDED"
|
|
|
|
|
|
FAILED = "FAILED"
|
|
|
|
|
|
REFUNDED = "REFUNDED"
|
|
|
|
|
|
STATUS_CHOICES = [
|
|
|
|
|
|
(PENDING, "Pending"),
|
|
|
|
|
|
(SUCCEEDED, "Succeeded"),
|
|
|
|
|
|
(FAILED, "Failed"),
|
|
|
|
|
|
(REFUNDED, "Refunded"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="purchases")
|
|
|
|
|
|
shop_item = models.ForeignKey(ShopItem, on_delete=models.PROTECT, related_name="purchases")
|
|
|
|
|
|
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
|
|
|
|
|
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
|
|
|
|
|
|
amount_cents = models.PositiveIntegerField() # snapshot — ShopItem price may change later
|
|
|
|
|
|
granted_writs = models.PositiveIntegerField(default=0) # snapshot
|
|
|
|
|
|
granted_token_ids = models.JSONField(default=list, blank=True)
|
|
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
|
|
succeeded_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
|
ordering = ["-created_at"]
|
|
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
|
return f"Purchase({self.user_id}, {self.shop_item.slug}, {self.status})"
|
|
|
|
|
|
|
|
|
|
|
|
def fulfill(self):
|
|
|
|
|
|
"""Mint tokens + grant writs. Idempotent — re-firing on a row that's
|
|
|
|
|
|
already SUCCEEDED is a safe no-op (the webhook + the sync
|
|
|
|
|
|
`/shop/confirm` view both call this; whichever lands first wins).
|
|
|
|
|
|
|
|
|
|
|
|
Failures elsewhere shouldn't reach this method — `status=FAILED`
|
|
|
|
|
|
rows stay FAILED + don't fulfill."""
|
|
|
|
|
|
from django.db import transaction
|
|
|
|
|
|
if self.status == self.SUCCEEDED:
|
|
|
|
|
|
return
|
|
|
|
|
|
if self.status not in (self.PENDING,):
|
|
|
|
|
|
# FAILED / REFUNDED — refuse to fulfill.
|
|
|
|
|
|
return
|
|
|
|
|
|
item = self.shop_item
|
|
|
|
|
|
with transaction.atomic():
|
|
|
|
|
|
granted_ids = []
|
|
|
|
|
|
for _ in range(item.granted_count):
|
|
|
|
|
|
t = Token.objects.create(
|
|
|
|
|
|
user=self.user,
|
|
|
|
|
|
token_type=item.granted_token_type,
|
|
|
|
|
|
)
|
|
|
|
|
|
granted_ids.append(t.pk)
|
|
|
|
|
|
if self.granted_writs:
|
|
|
|
|
|
wallet = self.user.wallet
|
|
|
|
|
|
wallet.writs = wallet.writs + self.granted_writs
|
|
|
|
|
|
wallet.save(update_fields=["writs"])
|
|
|
|
|
|
self.granted_token_ids = granted_ids
|
|
|
|
|
|
self.status = self.SUCCEEDED
|
|
|
|
|
|
self.succeeded_at = timezone.now()
|
|
|
|
|
|
self.save(update_fields=[
|
|
|
|
|
|
"granted_token_ids", "status", "succeeded_at",
|
|
|
|
|
|
])
|
2026-03-09 01:07:16 -04:00
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
|
@receiver(post_save, sender=User)
|
|
|
|
|
|
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
|
|
|
|
|
if not created:
|
|
|
|
|
|
return
|
2026-03-24 21:07:01 -04:00
|
|
|
|
from apps.epic.models import DeckVariant
|
2026-03-08 15:14:41 -04:00
|
|
|
|
Wallet.objects.create(user=instance, writs=144)
|
2026-03-16 00:07:52 -04:00
|
|
|
|
coin = Token.objects.create(user=instance, token_type=Token.COIN)
|
2026-03-08 15:14:41 -04:00
|
|
|
|
Token.objects.create(
|
|
|
|
|
|
user=instance,
|
|
|
|
|
|
token_type=Token.FREE,
|
|
|
|
|
|
expires_at=timezone.now() + timedelta(days=7),
|
|
|
|
|
|
)
|
2026-03-14 22:00:16 -04:00
|
|
|
|
if instance.is_staff:
|
2026-03-16 00:07:52 -04:00
|
|
|
|
pass_token = Token.objects.create(user=instance, token_type=Token.PASS)
|
|
|
|
|
|
instance.equipped_trinket = pass_token
|
|
|
|
|
|
else:
|
|
|
|
|
|
instance.equipped_trinket = coin
|
2026-03-24 21:07:01 -04:00
|
|
|
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
|
|
|
|
|
instance.equipped_deck = earthman
|
|
|
|
|
|
instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
|
2026-03-24 22:25:25 -04:00
|
|
|
|
if earthman:
|
|
|
|
|
|
instance.unlocked_decks.add(earthman)
|
2026-04-28 01:30:02 -04:00
|
|
|
|
if instance.is_superuser:
|
|
|
|
|
|
from apps.drama.models import Note
|
|
|
|
|
|
Note.grant_if_new(instance, "super-schizo")
|
|
|
|
|
|
Note.grant_if_new(instance, "super-nomad")
|