Files
python-tdd/src/apps/epic/models.py

951 lines
39 KiB
Python
Raw Normal View History

import random
import uuid
from datetime import timedelta
from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils import timezone
from apps.lyric.models import Token
class Room(models.Model):
GATHERING = "GATHERING"
OPEN = "OPEN"
RENEWAL_DUE = "RENEWAL_DUE"
GATE_STATUS_CHOICES = [
(GATHERING, "GATHERING GAMERS"),
(OPEN, "Open"),
(RENEWAL_DUE, "Renewal Due"),
]
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
INVITE_ONLY = "INVITE ONLY"
VISIBILITY_CHOICES = [
(PRIVATE, "Private"),
(PUBLIC, "Public"),
(INVITE_ONLY, "Invite Only"),
]
ROLE_SELECT = "ROLE_SELECT"
SIG_SELECT = "SIG_SELECT"
SKY_SELECT = "SKY_SELECT"
IN_GAME = "IN_GAME"
TABLE_STATUS_CHOICES = [
(ROLE_SELECT, "Role Select"),
(SIG_SELECT, "Significator Select"),
(SKY_SELECT, "Sky Select"),
(IN_GAME, "In Game"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_rooms"
)
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
table_status = models.CharField(
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
)
sig_select_started_at = models.DateTimeField(null=True, blank=True)
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12)
class GateSlot(models.Model):
EMPTY = "EMPTY"
RESERVED = "RESERVED"
FILLED = "FILLED"
STATUS_CHOICES = [
(EMPTY, "Empty"),
(RESERVED, "Reserved"),
(FILLED, "Filled"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="gate_slots")
slot_number = models.IntegerField()
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="gate_slots"
)
funded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="funded_slots"
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
reserved_at = models.DateTimeField(null=True, blank=True)
filled_at = models.DateTimeField(null=True, blank=True)
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
class RoomInvite(models.Model):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
DECLINED = "DECLINED"
STATUS_CHOICES = [
(PENDING, "Pending"),
(ACCEPTED, "Accepted"),
(DECLINED, "Declined"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="invites")
inviter = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="sent_invites"
)
invitee_email = models.EmailField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
created_at = models.DateTimeField(auto_now_add=True)
@receiver(post_save, sender=Room)
def create_gate_slots(sender, instance, created, **kwargs):
if created:
for i in range(1, 7):
GateSlot.objects.create(room=instance, slot_number=i)
def select_token(user):
fix: gate token-picker now equip-gated — `User.equipped_trinket` is the sole opt-in for trinket-as-token use at BOTH gatekeepers (`/gameboard/room/<id>/gate/` + `/gameboard/my-sea/gate/`). Old flat-priority chain (PASS→BAND→COIN→FREE→TITHE) silently consumed a DOFFed-but-owned COIN when the user clicked the rails — `current_room` advanced, no inventory decrement, wallet looked unchanged. User-reported 2026-05-21 as "free for all" admit when no trinket equipped. Root cause: `select_token` + `_select_my_sea_token` ignored `equipped_trinket_id` entirely + just grabbed the highest-priority owned token regardless of equip state, making the equip slot a decorative no-op. **Fix**: both pickers now start from `user.equipped_trinket_id`; equipped PASS (staff)/BAND/COIN-with-no-current-room → return it; equipped CARTE → fall through (CARTE is opt-in via kit-bag click that sets `token_id` POST param routed through `drop_token`'s explicit branch, NOT `select_token`); my-sea additionally checks COIN cooldown (`next_ready_at <= now`); no equipped trinket OR equipped trinket invalid → FREE (FEFO) → TITHE → None. **Fresh-query defense**: pickers query `user.tokens.filter(pk=user.equipped_trinket_id).first()` instead of the cached `user.equipped_trinket` FK descriptor — descriptor goes stale across mid-request state changes + bites tests where `tokens.all().delete()` triggers SET_NULL cascade but the Python object stays unrefreshed (SQLite reuses deleted PKs so a coincidentally-matching new token slips through). TDD — new `SelectTokenEquipGatedTest` (7 ITs) + `SelectMySeaTokenEquipGatedTest` (6 ITs) pin: skip-unequipped-COIN → FREE; skip-unequipped-BAND → TITHE; no equip + no consumables → None; CARTE equipped → falls through; equipped-COIN-in-use-elsewhere falls through; staff with unequipped PASS falls through; my-sea cooldown-COIN-equipped falls through. **Existing tests updated** (5 cases pinned the old flat-priority semantic + needed equipping explicit before assertion): `SelectTokenTest.test_returns_pass_for_staff` + `test_returns_band_when_equipped` + `test_pass_wins_when_equipped_over_band` + `SelectMySeaTokenTest.test_pass_wins_priority_for_staff` (now equip PASS first); `ConfirmTokenPriorityViewTest.test_pass_not_consumed_and_coin_not_leased` + `TokenPriorityTest.test_staff_backstage_pass_bypasses_token_cost` (FT) now DON the PASS before clicking rails. `SelectMySeaTokenTest.setUp` adds `refresh_from_db()` after `tokens.all().delete()` so the cascade SET_NULL on equipped_trinket_id is reflected in the Python object. 1160 IT/UT + 5 TokenPriority FTs green. Trap captured: [[feedback-equip-slot-gates-trinket-use]] Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:56:59 -04:00
"""Pick a token for `drop_token`'s rails-click flow (no explicit
kit-bag choice). Equip-gated: trinkets (PASS/BAND/COIN) must be DON-ed
to fire; CARTE is opt-in only (kit-bag click sets a `token_id` POST
param that bypasses this picker). No equipped trinket OR equipped
trinket invalid for this gate fall back to FREE (FEFO) TITHE None.
Bug 2026-05-21 fix: previous flat-priority chain (PASS BAND COIN
FREE TITHE, regardless of equip state) silently consumed a DOFFed
COIN user saw nothing change in the wallet ("free for all" symptom).
Equip slot now gates trinket use entirely. See [[feedback-equip-slot-
gates-trinket-use]] for the rationale.
"""
# Query the trinket fresh from the user's tokens (not via the cached
# FK descriptor) — defensive against stale Token state from earlier
# in the request lifecycle + cheap filter on the owned-set so a
# dangling FK to a deleted token resolves to None instead of crashing.
if user.equipped_trinket_id is not None:
trinket = user.tokens.filter(pk=user.equipped_trinket_id).first()
else:
trinket = None
if trinket is not None:
if trinket.token_type == Token.PASS and user.is_staff:
return trinket
if trinket.token_type == Token.BAND:
return trinket
if trinket.token_type == Token.COIN and trinket.current_room_id is None:
return trinket
# CARTE excluded — opt-in via explicit kit-bag click; idle CARTE-
# holders get FREE/TITHE fallback.
free = user.tokens.filter(
token_type=Token.FREE,
expires_at__gt=timezone.now(),
).order_by("expires_at").first()
if free:
return free
return user.tokens.filter(token_type=Token.TITHE).first()
def debit_token(user, slot, token):
slot.debited_token_type = token.token_type
if token.token_type == Token.COIN:
token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period
token.save()
COIN: unequip on deposit (parity w. CARTE) ; fix FT false-positive masking the bug User reported on iPhone: after depositing a COIN at a game's gatekeeper, the Kit Bag's Trinket slot still shows the COIN — even though the tooltip correctly carries the room attribution ("Ready 2026-05-25 / Billingsworth"). Expected behavior matches CARTE: the deposited token disappears from the Kit Bag Trinket slot because it's committed elsewhere & can't be re-used as the active trinket until released. PASS preserved — auto-admits w.o ever going thru the deposit path so it stays equipped ; **the real bug**: `debit_token` in epic/models.py's COIN branch set `current_room` + `next_ready_at` but never cleared `user.equipped_trinket`. CARTE's `drop_token` view (epic/views.py:440-442) explicitly unequips at deposit time via `user.equipped_trinket = None; user.save(update_fields=["equipped_trinket"])`; COIN had no parity. Fix: same 4-line unequip stanza now lives inside the COIN branch of `debit_token`, guarded by `if user.equipped_trinket_id == token.pk` so a fresh-purchased COIN deposit (not the equipped one) doesn't accidentally clear another trinket. PASS untouched — falls thru `debit_token` w.o entering any branch & never reaches this path; CARTE untouched too (its branch is `pass`, unequip happens at `drop_token` time before debit_token is even called) ; **the FT false-positive**: yesterday's Sprint 2 commit (d2491c5) shipped `test_coin_deposit_unequips_from_kit_bag_and_fills_one_slot` w. selector `#id_kit_bag_dialog .kit-bag-placeholder`. That selector was matching the **Dice** section's placeholder (Dice feature isn't built — `_kit_bag_panel.html` L23-29 renders `.kit-bag-placeholder` unconditionally), masking the bug whether or not the Trinket section was empty. Tightened to `.kit-bag-section--trinket .kit-bag-placeholder` w. comment explaining why a bare selector is unsafe ; template change in `_kit_bag_panel.html` L31: Trinket section gains a `kit-bag-section--trinket` modifier class so the FT (and any future selector that needs to single out the trinket section vs the deck/dice/tokens siblings) has an anchor. Mirrors the existing `kit-bag-section--tokens` class at L70 ; TDD trail: (1) tightened selector + reran → red on `NoSuchElement` (no `.kit-bag-section--trinket .kit-bag-placeholder` because COIN still equipped post-deposit, so trinket section renders the token card not the placeholder); (2) added unequip stanza to debit_token; (3) reran → green. 10 trinket FTs in 99s; 999 IT/UT in 46s — no regressions ; **generalizable trap**: when an FT waits for an element via a CSS selector, scope the selector to the section/container that uniquely identifies the assertion target — a class like `.kit-bag-placeholder` that's reused across multiple sections will silently pass even when the section you care about is in the wrong state. This is the second false-positive trap in two days (cf. d2491c5's wrong-selector trap where `.token-slot.claimed` was Carte-specific); pattern's worth noting Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:35:08 -04:00
# Parity w. CARTE's drop_token unequip: a deposited COIN is committed
# elsewhere & can't be re-used as the active trinket until the deposit
# is released, so clear `equipped_trinket` to drop it out of the Kit
# Bag's Trinket slot. PASS stays equipped (auto-admits, never deposits).
if user.equipped_trinket_id == token.pk:
user.equipped_trinket = None
user.save(update_fields=["equipped_trinket"])
elif token.token_type == Token.CARTE:
pass # current_room already set in drop_token; token not consumed
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
elif token.token_type not in (Token.PASS, Token.BAND):
slot.debited_token_expires_at = token.expires_at
token.delete()
slot.gamer = user
slot.status = GateSlot.FILLED
slot.filled_at = timezone.now()
slot.save()
room = slot.room
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
room.gate_status = Room.OPEN
room.save()
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
class TableSeat(models.Model):
PC = "PC"
BC = "BC"
SC = "SC"
AC = "AC"
NC = "NC"
EC = "EC"
ROLE_CHOICES = [
(PC, "Player"),
(BC, "Builder"),
(SC, "Shepherd"),
(AC, "Alchemist"),
(NC, "Narrator"),
(EC, "Economist"),
]
PARTNER_MAP = {PC: BC, BC: PC, SC: AC, AC: SC, NC: EC, EC: NC}
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="table_seats")
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="table_seats"
)
slot_number = models.IntegerField()
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
role_revealed = models.BooleanField(default=False)
seat_position = models.IntegerField(null=True, blank=True)
significator = models.ForeignKey(
"TarotCard", null=True, blank=True,
on_delete=models.SET_NULL, related_name="significator_seats",
)
deck_variant = models.ForeignKey(
"DeckVariant", null=True, blank=True,
on_delete=models.SET_NULL, related_name="active_seats",
)
class DeckVariant(models.Model):
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
"""A named deck variant, e.g. Earthman or Tarot (Rider-Waite-Smith)."""
EARTHMAN = "earthman"
ITALIAN = "italian"
ENGLISH = "english"
PLAYING = "playing"
FAMILY_CHOICES = [
(EARTHMAN, "Earthman"),
(ITALIAN, "Italian / Minchiate"),
(ENGLISH, "English Tarot"),
(PLAYING, "Playing card"),
]
A.2 TarotCard.image_filename + display_suit_name properties — TDD. Sprint A.2 of [[project-image-based-deck-face-rendering]]. Adds two per-card derived properties that consume the new `DeckVariant.family` field (locked in A.0) to translate canonical-Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES) into family-authentic filename slugs + UI labels per [[reference-card-image-naming-convention]] v2. `DeckVariant` gains the family-mapping tables + methods (`suit_slug` / `suit_display` / `trump_category`); `TarotCard` consumes them via `image_filename` + `display_suit_name`. Two mapping tables live on DeckVariant (single source of truth for per-family vocab): `_SUIT_SLUG_BY_FAMILY` (4 families × 4 suits = 16 entries: earthman is identity-mapped {BRANDS→brands, CROWNS→crowns, GRAILS→grails, BLADES→blades}; italian is {BRANDS→batons, CROWNS→coins, GRAILS→cups, BLADES→swords}; english is {BRANDS→wands, CROWNS→pentacles, GRAILS→cups, BLADES→swords}; playing is {BRANDS→clubs, CROWNS→diamonds, GRAILS→hearts, BLADES→spades}) and `_TRUMP_CATEGORY_BY_FAMILY` (earthman+italian use "trumps", english uses "majors" matching Modern Tarot's "Major Arcana", playing is None since 52-card decks have no trump category — jokers handled separately when a playing deck is seeded). `DeckVariant.suit_slug(canonical)` returns the filename slug; `suit_display(canonical)` returns capitalized UI label (via slug.capitalize()); `trump_category` is a property since it takes no per-card argument. `TarotCard.image_filename` branches on arcana: MAJOR returns `<deck-slug>-<trump-category>-<NN>-<card-slug>.png` (NN = zero-padded number per v2 convention, e.g. 00 for Il Matto; card-slug carries the italian name like "il-gobbo" or english like "the-fool"); MINOR/MIDDLE returns `<deck-slug>-<suit-slug>-<NN>[-<court>].png` where court suffix is "page"/"knight"/"queen"/"king" for ranks 11-14 (tarot family courts; playing-family's 3-court jack/queen/king deferred to playing-deck-seed sprint). `display_suit_name` returns capitalized family-authentic suit name ("Batons" for italian BRANDS, "Pentacles" for english CROWNS) or empty string for major arcana (no suit). Both properties are pure-derived — no schema migration needed, no DB writes; the template (Sprint A.3+) decides whether to render <img src=image_filename> based on `deck.has_card_images`. RWS deck's image_filename returns a path even though has_card_images=False (path is correct per convention; just no file exists at that path yet — once RWS images are sourced, flip the flag). 17 new ITs in `CardImageFilenameA2Test` cover: Minchiate trumps (Il Matto rank-00, Il Gobbo rank-11, Le Trombe rank-40, L'Acqua rank-21 w. apostrophe-restored slug); Minchiate minors (Ace of Batons pip-with-no-court-suffix, Ten of Coins, Page of Cups w. court suffix, King of Swords); RWS post-revocab (Ace of Cups uses english-family "cups" slug despite suit=GRAILS, The Fool uses "majors" category, King of Pentacles uses "pentacles" slug despite suit=CROWNS); Earthman identity-mapped (BRANDS→brands); display_suit_name across all 3 tarot families (italian BRANDS→"Batons", italian CROWNS→"Coins", english CROWNS→"Pentacles", earthman BRANDS→"Brands"); empty for majors. Tests: 17 new green; 1289/1289 IT+UT total green (63s; +17 from A.1's 1272). Out of scope: A.3 wires my_sign.html's first render branch (the visible-win first surface); A.4 builds card-deck icon + game_kit applet; A.5-A.8 DRY across my_sea + both billboard applets + room Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:37:16 -04:00
# Per-family translation tables: canonical SUIT enum (Earthman vocab) →
# family-authentic display slug used in image filenames + UI labels.
# See [[reference-card-image-naming-convention]] v2.
_SUIT_SLUG_BY_FAMILY = {
EARTHMAN: {"BRANDS": "brands", "CROWNS": "crowns", "GRAILS": "grails", "BLADES": "blades"},
ITALIAN: {"BRANDS": "batons", "CROWNS": "coins", "GRAILS": "cups", "BLADES": "swords"},
ENGLISH: {"BRANDS": "wands", "CROWNS": "pentacles", "GRAILS": "cups", "BLADES": "swords"},
PLAYING: {"BRANDS": "clubs", "CROWNS": "diamonds", "GRAILS": "hearts", "BLADES": "spades"},
}
_TRUMP_CATEGORY_BY_FAMILY = {
EARTHMAN: "trumps",
ITALIAN: "trumps",
ENGLISH: "majors",
PLAYING: None, # 52-card decks: no trump category (jokers handled separately)
}
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
card_count = models.IntegerField()
description = models.TextField(blank=True)
is_default = models.BooleanField(default=False)
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN)
has_card_images = models.BooleanField(default=True)
is_polarized = models.BooleanField(default=False)
image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd) Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision. **(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26: - earthman family → "default" (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration) - slug startswith "tarot-" → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english) - otherwise → uses slug as-is (italian/minchiate-fiorentine-1860-1890) Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`). Why this naming over alternatives the user considered: - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition) - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks) - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum) No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered. **(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries. **(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight). Filename normalization via one-shot `d:/tmp/rename_rws.py`: - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png` - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping) - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`) - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both) - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png` Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file. pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB. Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh. Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00
@property
def variant_dir_slug(self):
"""Subdirectory under `cards-faces/<family>/` for this deck's images.
Strips family-implied prefixes from `slug` (e.g., RWS slug is
`tarot-rider-waite-smith` but lives at `english/rider-waite-smith/`
the "tarot-" is redundant under family=english). Earthman is special-
cased to "default" per user-locked spec 2026-05-26: even though it's
currently a single canonical deck, we lock in the variant tier now
so future Earthman editions slot in alongside as `earthman/<variant>/`
w.o. a path migration.
Mapping today:
earthman / earthman earthman/default
italian / minchiate-... italian/minchiate-fiorentine-1860-1890
english / tarot-rws english/rider-waite-smith (strip "tarot-")
"""
if self.family == self.EARTHMAN:
return "default"
if self.slug.startswith("tarot-"):
return self.slug[len("tarot-"):]
return self.slug
A.4 cont.: deck back-image renders inside card-stack icon + kit-bag dialog Deck section adopts the icon + size+pattern polish — TDD. Three follow-up improvements after user browser-verified A.4's first cut: (1) image-equipped decks (Minchiate today, future Earthman) now render the deck's actual <deck-slug>-back.png as the card-stack icon's visible faces instead of the placeholder --priUser solid fill — feels like a real deck, not a generic stand-in. (2) The kit-bag dialog Deck section (`#id_kit_bag_dialog .kit-bag-deck`) gets the same new card-stack icon (was still showing the old fa-regular fa-id-badge), with `(×2)` tooltip decoration on polarized decks for consistency w. the gameboard applet. (3) Visual polish: icon bumped 1.5× (1.5rem → 2.25rem width; 2.4rem → 3.6rem height, 5:8 aspect preserved); SVG <pattern> switched from `patternUnits=userSpaceOnUse` (which painted the image at fixed user-space coordinates and let the rect slide out from under it on hover, reading as "low opacity" to the user) to `patternUnits=objectBoundingBox + patternContentUnits=objectBoundingBox` (transform-aware — image tracks the rect through rest-state offsets + hover fan-out). New `DeckVariant.back_image_url` property mirrors A.2's `TarotCard.image_url` pattern: returns full static-asset URL for `<deck-slug>-back.png` when has_card_images=True, else empty string. Template partial `_deck_stack_icon.html` extended w. conditional `<defs><pattern>` block that renders only when `deck.has_card_images` is true; each of the 3 card rects then carries an inline `style="fill: url(#deck-back-<short_key>)"` overriding the SCSS default `fill: rgba(--priUser, 1)` (inline style beats CSS, the only way to opt out of the cascade default per-element). When no deck is passed (kit-bag placeholder branch) or deck has no images (Earthman + RWS), the partial falls through to the placeholder fill — single template handles both modes. `_kit_bag_panel.html` Deck section: equipped-deck branch swaps `<i class="fa-regular fa-id-badge">` for `{% include _deck_stack_icon.html with deck=equipped_deck %}` + adds `(×2)` span in --terUser for `equipped_deck.is_polarized`; placeholder branch swaps for the same include without `deck=` so the partial's conditional falls through. SCSS reorg: lifted the `.deck-stack-icon` base rules out of the `#id_applet_game_kit` nest (they were scoped to gameboard's Game Kit applet only) into top-level scope so the same SCSS applies in the kit-bag dialog context too. Hover/active/focus trigger selector list broadened to cover `.deck-stack-icon` itself + `.token.deck-variant` wrapper + `.kit-bag-deck` wrapper. 4 new ITs total: 2 in `GameboardViewTest` (image-equipped Minchiate's <pattern> defines + inline fill style on all 3 rects + asset URL ref; non-image Earthman has NEITHER pattern nor inline fill); 2 in `dashboard.KitBagViewTest` (kit-bag Deck section renders svg.deck-stack-icon + lacks fa-id-badge; polarized equipped deck tooltip carries .tt-x2 — element-presence assertion since literal "×2" character had encoding issues in the dashboard test file vs the gameboard one, which is fine since the template-side rendering of the literal × is exercised by the parent template). Tests: 4 new green; 1297/1297 IT+UT total green (69s; +4 from A.4's 1293). Visual verify pending: refresh /gameboard/ → Minchiate icon should show 3 stacked Minchiate card-backs at 1.5× size, fan out on hover w. back image tracking; refresh kit-bag dialog → same icon visible in Deck section w. (×2) on Earthman tooltip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:01:05 -04:00
@property
def back_image_url(self):
"""Full static-asset URL for this deck's card-back image, or empty
string if the deck has no images (legacy text-only mode). Sprint A.4
consumed by the card-stack icon SVG to render the actual deck back
as the visible card-stack rect-fills instead of the placeholder
`--priUser` solid color."""
if not self.has_card_images:
return ""
from django.templatetags.static import static
return static(
image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd) Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision. **(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26: - earthman family → "default" (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration) - slug startswith "tarot-" → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english) - otherwise → uses slug as-is (italian/minchiate-fiorentine-1860-1890) Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`). Why this naming over alternatives the user considered: - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition) - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks) - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum) No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered. **(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries. **(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight). Filename normalization via one-shot `d:/tmp/rename_rws.py`: - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png` - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping) - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`) - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both) - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png` Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file. pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB. Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh. Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00
f"apps/epic/images/cards-faces/{self.family}/{self.variant_dir_slug}/{self.slug}-back.png"
A.4 cont.: deck back-image renders inside card-stack icon + kit-bag dialog Deck section adopts the icon + size+pattern polish — TDD. Three follow-up improvements after user browser-verified A.4's first cut: (1) image-equipped decks (Minchiate today, future Earthman) now render the deck's actual <deck-slug>-back.png as the card-stack icon's visible faces instead of the placeholder --priUser solid fill — feels like a real deck, not a generic stand-in. (2) The kit-bag dialog Deck section (`#id_kit_bag_dialog .kit-bag-deck`) gets the same new card-stack icon (was still showing the old fa-regular fa-id-badge), with `(×2)` tooltip decoration on polarized decks for consistency w. the gameboard applet. (3) Visual polish: icon bumped 1.5× (1.5rem → 2.25rem width; 2.4rem → 3.6rem height, 5:8 aspect preserved); SVG <pattern> switched from `patternUnits=userSpaceOnUse` (which painted the image at fixed user-space coordinates and let the rect slide out from under it on hover, reading as "low opacity" to the user) to `patternUnits=objectBoundingBox + patternContentUnits=objectBoundingBox` (transform-aware — image tracks the rect through rest-state offsets + hover fan-out). New `DeckVariant.back_image_url` property mirrors A.2's `TarotCard.image_url` pattern: returns full static-asset URL for `<deck-slug>-back.png` when has_card_images=True, else empty string. Template partial `_deck_stack_icon.html` extended w. conditional `<defs><pattern>` block that renders only when `deck.has_card_images` is true; each of the 3 card rects then carries an inline `style="fill: url(#deck-back-<short_key>)"` overriding the SCSS default `fill: rgba(--priUser, 1)` (inline style beats CSS, the only way to opt out of the cascade default per-element). When no deck is passed (kit-bag placeholder branch) or deck has no images (Earthman + RWS), the partial falls through to the placeholder fill — single template handles both modes. `_kit_bag_panel.html` Deck section: equipped-deck branch swaps `<i class="fa-regular fa-id-badge">` for `{% include _deck_stack_icon.html with deck=equipped_deck %}` + adds `(×2)` span in --terUser for `equipped_deck.is_polarized`; placeholder branch swaps for the same include without `deck=` so the partial's conditional falls through. SCSS reorg: lifted the `.deck-stack-icon` base rules out of the `#id_applet_game_kit` nest (they were scoped to gameboard's Game Kit applet only) into top-level scope so the same SCSS applies in the kit-bag dialog context too. Hover/active/focus trigger selector list broadened to cover `.deck-stack-icon` itself + `.token.deck-variant` wrapper + `.kit-bag-deck` wrapper. 4 new ITs total: 2 in `GameboardViewTest` (image-equipped Minchiate's <pattern> defines + inline fill style on all 3 rects + asset URL ref; non-image Earthman has NEITHER pattern nor inline fill); 2 in `dashboard.KitBagViewTest` (kit-bag Deck section renders svg.deck-stack-icon + lacks fa-id-badge; polarized equipped deck tooltip carries .tt-x2 — element-presence assertion since literal "×2" character had encoding issues in the dashboard test file vs the gameboard one, which is fine since the template-side rendering of the literal × is exercised by the parent template). Tests: 4 new green; 1297/1297 IT+UT total green (69s; +4 from A.4's 1293). Visual verify pending: refresh /gameboard/ → Minchiate icon should show 3 stacked Minchiate card-backs at 1.5× size, fan out on hover w. back image tracking; refresh kit-bag dialog → same icon visible in Deck section w. (×2) on Earthman tooltip Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 01:01:05 -04:00
)
A.2 TarotCard.image_filename + display_suit_name properties — TDD. Sprint A.2 of [[project-image-based-deck-face-rendering]]. Adds two per-card derived properties that consume the new `DeckVariant.family` field (locked in A.0) to translate canonical-Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES) into family-authentic filename slugs + UI labels per [[reference-card-image-naming-convention]] v2. `DeckVariant` gains the family-mapping tables + methods (`suit_slug` / `suit_display` / `trump_category`); `TarotCard` consumes them via `image_filename` + `display_suit_name`. Two mapping tables live on DeckVariant (single source of truth for per-family vocab): `_SUIT_SLUG_BY_FAMILY` (4 families × 4 suits = 16 entries: earthman is identity-mapped {BRANDS→brands, CROWNS→crowns, GRAILS→grails, BLADES→blades}; italian is {BRANDS→batons, CROWNS→coins, GRAILS→cups, BLADES→swords}; english is {BRANDS→wands, CROWNS→pentacles, GRAILS→cups, BLADES→swords}; playing is {BRANDS→clubs, CROWNS→diamonds, GRAILS→hearts, BLADES→spades}) and `_TRUMP_CATEGORY_BY_FAMILY` (earthman+italian use "trumps", english uses "majors" matching Modern Tarot's "Major Arcana", playing is None since 52-card decks have no trump category — jokers handled separately when a playing deck is seeded). `DeckVariant.suit_slug(canonical)` returns the filename slug; `suit_display(canonical)` returns capitalized UI label (via slug.capitalize()); `trump_category` is a property since it takes no per-card argument. `TarotCard.image_filename` branches on arcana: MAJOR returns `<deck-slug>-<trump-category>-<NN>-<card-slug>.png` (NN = zero-padded number per v2 convention, e.g. 00 for Il Matto; card-slug carries the italian name like "il-gobbo" or english like "the-fool"); MINOR/MIDDLE returns `<deck-slug>-<suit-slug>-<NN>[-<court>].png` where court suffix is "page"/"knight"/"queen"/"king" for ranks 11-14 (tarot family courts; playing-family's 3-court jack/queen/king deferred to playing-deck-seed sprint). `display_suit_name` returns capitalized family-authentic suit name ("Batons" for italian BRANDS, "Pentacles" for english CROWNS) or empty string for major arcana (no suit). Both properties are pure-derived — no schema migration needed, no DB writes; the template (Sprint A.3+) decides whether to render <img src=image_filename> based on `deck.has_card_images`. RWS deck's image_filename returns a path even though has_card_images=False (path is correct per convention; just no file exists at that path yet — once RWS images are sourced, flip the flag). 17 new ITs in `CardImageFilenameA2Test` cover: Minchiate trumps (Il Matto rank-00, Il Gobbo rank-11, Le Trombe rank-40, L'Acqua rank-21 w. apostrophe-restored slug); Minchiate minors (Ace of Batons pip-with-no-court-suffix, Ten of Coins, Page of Cups w. court suffix, King of Swords); RWS post-revocab (Ace of Cups uses english-family "cups" slug despite suit=GRAILS, The Fool uses "majors" category, King of Pentacles uses "pentacles" slug despite suit=CROWNS); Earthman identity-mapped (BRANDS→brands); display_suit_name across all 3 tarot families (italian BRANDS→"Batons", italian CROWNS→"Coins", english CROWNS→"Pentacles", earthman BRANDS→"Brands"); empty for majors. Tests: 17 new green; 1289/1289 IT+UT total green (63s; +17 from A.1's 1272). Out of scope: A.3 wires my_sign.html's first render branch (the visible-win first surface); A.4 builds card-deck icon + game_kit applet; A.5-A.8 DRY across my_sea + both billboard applets + room Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:37:16 -04:00
def suit_slug(self, canonical_suit):
"""Map canonical SUIT enum → family-authentic filename slug.
e.g. ('italian', 'BRANDS') 'batons'."""
return self._SUIT_SLUG_BY_FAMILY[self.family][canonical_suit]
def suit_display(self, canonical_suit):
"""User-facing capitalized suit label, e.g. ('italian', 'BRANDS') → 'Batons'."""
return self.suit_slug(canonical_suit).capitalize()
@property
def trump_category(self):
"""Filename-slug category for trump cards in this family."""
return self._TRUMP_CATEGORY_BY_FAMILY[self.family]
@property
def short_key(self):
"""First dash-separated word of slug — used as an HTML id component."""
return self.slug.split('-')[0]
def __str__(self):
return f"{self.name} ({self.card_count} cards)"
class TarotCard(models.Model):
MAJOR = "MAJOR"
MINOR = "MINOR" # pip cards (numbers 1-10)
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K, numbers 11-14)
ARCANA_CHOICES = [
(MAJOR, "Major Arcana"),
(MINOR, "Minor Arcana"),
(MIDDLE, "Middle Arcana"),
]
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
# Canonical SUIT_CHOICES = Earthman vocabulary (2026-05-25 lock).
# Per-family display + filename slug mapping lives in image_filename /
# display_suit_name properties driven by DeckVariant.family.
BRANDS = "BRANDS"
CROWNS = "CROWNS"
GRAILS = "GRAILS"
BLADES = "BLADES"
SUIT_CHOICES = [
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
(BRANDS, "Brands"),
(CROWNS, "Crowns"),
(GRAILS, "Grails"),
(BLADES, "Blades"),
]
deck_variant = models.ForeignKey(
DeckVariant, null=True, blank=True,
on_delete=models.CASCADE, related_name="cards",
)
name = models.CharField(max_length=200)
arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana)
number = models.IntegerField() # 021 major (Fiorentine); 049 major (Earthman); 114 minor
slug = models.SlugField(max_length=120)
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
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
reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # polysemous (cf [[feedback-reversal-qualifier-dual-role]]): on non-Majors w. no polarity qualifier it's the reversal-face qualifier (e.g. "Vacant"); on Majors w. polarity qualifiers it's the NAME-SWAP for the reversal face (e.g. "Patrilineage" for card 34). `applet_face()` routes on `arcana`.
reversal_drops_qualifier = models.BooleanField(default=False) # Pattern B' cards (16-18): reversal face shows the name swap ALONE, no qualifier. Pattern B (default False): polarity qualifier persists on the reversal face.
levity_qualifier = models.CharField(max_length=100, blank=True, default='')
gravity_qualifier = models.CharField(max_length=100, blank=True, default='')
levity_emanation = models.CharField(max_length=200, blank=True, default='') # polarity-split upright (cards 48-49)
gravity_emanation = models.CharField(max_length=200, blank=True, default='')
levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48)
gravity_reversal = models.CharField(max_length=200, blank=True, default='')
italic_word = models.CharField(max_length=50, blank=True, default='') # word(s) inside any title slot to wrap in <em> at render time (e.g. "Stalking" for trumps 19-21)
energies = models.JSONField(default=list) # list of {type, effect} dicts — Energy interactions
operations = models.JSONField(default=list) # list of {type, effect} dicts — Operation interactions
keywords_upright = models.JSONField(default=list)
keywords_reversed = models.JSONField(default=list)
cautions = models.JSONField(default=list)
class Meta:
ordering = ["deck_variant", "arcana", "suit", "number"]
unique_together = [("deck_variant", "slug")]
polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock. **(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status. **(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment. **(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec. **(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here. **(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG). **(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific). **(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX). **(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it. **(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow. Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
# Per-trump overrides for Fiorentine Minchiate fidelity — the historical
# deck art uses additive numerals at these specific ranks only (NOT every
# 4/9 ending; e.g. trump 9 = IX, trump 14 = XIV stay subtractive per the
# actual printed cards). Earthman's 0-49 trumps inherit the same mapping
# for visual consistency w. the Fiorentine deck. Other ranks fall through
# to the standard subtractive `_to_roman` algorithm.
_FIORENTINE_ADDITIVE_NUMERALS = {
4: 'IIII',
19: 'XVIIII',
24: 'XXIIII',
29: 'XXVIIII',
34: 'XXXIIII',
39: 'XXXVIIII',
}
@staticmethod
def _to_roman(n):
if n == 0:
return '0'
polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock. **(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status. **(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment. **(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec. **(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here. **(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG). **(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific). **(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX). **(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it. **(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow. Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
if n in TarotCard._FIORENTINE_ADDITIVE_NUMERALS:
return TarotCard._FIORENTINE_ADDITIVE_NUMERALS[n]
val = [50, 40, 10, 9, 5, 4, 1]
syms = ['L','XL','X','IX','V','IV','I']
result = ''
for v, s in zip(val, syms):
while n >= v:
result += s
n -= v
return result
@property
def corner_rank(self):
if self.arcana == self.MAJOR:
return self._to_roman(self.number)
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
if self.number in court:
return court[self.number]
return 'A' if self.number == 1 else str(self.number)
def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity').
Falls back to name_title (group prefix stripped) for cards without a
polarity split."""
if polarity == 'levity' and self.levity_emanation:
return self.levity_emanation
if polarity == 'gravity' and self.gravity_emanation:
return self.gravity_emanation
return self.name_title
def reversal_for(self, polarity):
"""Return the reversed title for a given polarity.
Falls back to reversal_qualifier (blank = same as emanation_for)."""
if polarity == 'levity' and self.levity_reversal:
return self.levity_reversal
if polarity == 'gravity' and self.gravity_reversal:
return self.gravity_reversal
return self.reversal_qualifier or self.emanation_for(polarity)
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
def applet_face(self, polarity='gravity', reversed=False):
"""Return the rendering payload for a card face in the My Sign /
My Sea applets mirrors `populateCard` in `stage-card.js`. Four
patterns:
- **Polarity-split FULL title** (cards 19-21, 48-49): single-line
title from `emanation_for` / `reversal_for`; qualifier blank.
- **Pattern B Major w. polarity qualifier + reversal name-swap**
(cards 2-5, 10-15, 22-35, 41): `reversal_qualifier` carries the
REVERSAL-face NAME (e.g. "Patrilineage" for card 34). Polarity
qualifier persists across both faces. Renders: `<reversal_qual>,`
/ `<polarity_qualifier>` on the reversal face.
- **Pattern B' — Major w. name-swap that DROPS qualifier on
reversal** (cards 16-18 Realms): same as Pattern B but the
reversal face renders only the name (e.g. "Shame"), no
qualifier. Marked via `reversal_drops_qualifier=True`.
- **Non-Major (middle / minor)**: qualifier ABOVE title; reversal
face uses `reversal_qualifier` as the QUALIFIER (NOT a name
swap) e.g. "Queen of Crowns" stays as the title, "Vacant"
renders as the reversal qualifier.
Returns a 3-key dict:
{
"title": str, # title (w. trailing comma for Major+qual)
"qualifier": str, # qualifier text (may be blank)
"qualifier_first": bool, # True ⇒ qualifier above title; False ⇒ below
}
"""
is_major = (self.arcana == self.MAJOR)
if reversed:
override = (self.levity_reversal if polarity == 'levity'
else self.gravity_reversal)
if override:
return {"title": override, "qualifier": "", "qualifier_first": False}
polarity_qualifier = (
self.levity_qualifier if polarity == 'levity'
else self.gravity_qualifier
)
# Pattern B / B' — Major w. both polarity qualifier + reversal
# name-swap. `reversal_qualifier` is the SWAPPED NAME (not a
# qualifier) for these Majors. See `reversal_qualifier` field
# docstring + [[feedback-reversal-qualifier-dual-role]].
if is_major and self.reversal_qualifier and polarity_qualifier:
if self.reversal_drops_qualifier:
# Pattern B' (16-18): single-line reversal name.
return {"title": self.reversal_qualifier,
"qualifier": "", "qualifier_first": False}
# Pattern B (2-5, 10-15, 22-35, 41): swapped name + polarity
# qualifier carried across both faces.
return {"title": self.reversal_qualifier + ",",
"qualifier": polarity_qualifier,
"qualifier_first": False}
# Non-Major OR Major-without-polarity-qualifier: reversal_
# qualifier is the qualifier (Pattern A / fallback).
qualifier = self.reversal_qualifier or polarity_qualifier
else:
override = (self.levity_emanation if polarity == 'levity'
else self.gravity_emanation)
if override:
return {"title": override, "qualifier": "", "qualifier_first": False}
qualifier = (self.levity_qualifier if polarity == 'levity'
else self.gravity_qualifier)
title = self.name_title
if is_major and qualifier:
return {"title": title + ",", "qualifier": qualifier,
"qualifier_first": False}
return {"title": title, "qualifier": qualifier, "qualifier_first": True}
@property
def name_group(self):
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
if ': ' in self.name:
return self.name.split(': ', 1)[0] + ':'
return ''
@property
def name_title(self):
"""Returns the title after 'Group N: ', or the full name if no colon."""
if ': ' in self.name:
return self.name.split(': ', 1)[1]
return self.name
@property
def title_squeeze_class(self):
"""No-op kept for template compatibility. Title fit is now handled by
a smaller base `font-size` on `.fan-card-name`/`.fan-card-reversal-*`
plus `text-wrap: balance` (see `_card-deck.scss`) every long-title
card fits naturally without per-card CSS hacks."""
return ''
@property
def suit_icon(self):
polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock. **(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status. **(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment. **(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec. **(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here. **(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG). **(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific). **(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX). **(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it. **(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow. Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
if self.arcana == self.MAJOR:
# Trump 0 (Fool / Nomad / Matto) + trump 1 (Magician / Schizo /
# Bagatto) carry universal symbol overrides — cowboy-hat-side for
# the wanderer/fool archetype, wizard-hat for the magus archetype.
# Pinned BEFORE the `self.icon` branch so even a deck seed that
# supplies a different icon for these two ranks gets normalized
# to the convention (Earthman's seed already aligns; Minchiate's
# empty icon field used to fall through to fa-hand-dots).
if self.number == 0:
return 'fa-hat-cowboy-side'
if self.number == 1:
return 'fa-hat-wizard'
if self.icon:
return self.icon
if self.arcana == self.MAJOR:
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually. (1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing. (2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics). (3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use. (4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`. (5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title. Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
# Sprint A.7.5 — trumps default to fa-hand-dots so the chip (and
# any text-mode corner) always has a symbol below the rank. Per-
# card overrides still win via the `self.icon` branch above (the
# Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps
# 2+, which was the only place this fallback used to live; trumps
polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock. **(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status. **(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment. **(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec. **(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here. **(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG). **(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific). **(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX). **(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it. **(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow. Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload. Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
# 2+ Minchiate trumps still pick it up for free here).
A.7.5 Game Kit carousel image-mode + universal stat-block top-left chip + EMANATION/REVERSAL --secUser convention — TDD. Mid-session 2026-05-25 PM (Sprint A.7.5 of [[project-image-based-deck-face-rendering]] — slotted between A.7 polish + tomorrow's A.8 room.html). Three threads bundled: (1) Game Kit `_tarot_fan.html` carousel modal gets the image-mode branch + per-card FLIP-to-back for non-polarized image-equipped decks (Minchiate today; brings the carousel into parity w. the other 5 image-mode surfaces shipped in A.3-A.7); (2) the A.3 Q3-spec top-left rank+suit chip lands across all 4 stat-block surfaces (my_sign main / _applet-my-sign / _sea_stage modal / new game_kit fan stage), retrofitting work that A.3 explicitly deferred per the "Lower-priority follow-ups" list in the project memory; (3) chip + EMANATION/REVERSAL label adopt --secUser as the new universal color convention so the title (--quaUser/--terUser per arcana) stays the focal text + the chip-and-label header recedes visually. (1) _tarot_fan.html image-mode branch — server-side `{% if card.deck_variant.has_card_images %}` gate: image-mode renders `<img class="sig-stage-card-img">` + (for non-polarized decks) a sibling `<img class="sig-stage-card-back-img">` for the FLIP-to-back affordance; text-mode keeps the existing `.fan-card-corner --tl/--br` + `.fan-card-face` scaffold unchanged (Earthman + RWS today; will be removed once both decks get artwork — user's plan: scrape RWS art tonight + Earthman public-domain paintings to follow; "shabby cardstock" non-equippable Earthman variant retains text rendering as legacy preservation). New `.fan-card.fan-card--image` marker class added to the shared image-mode comma-list selector (`_card-deck.scss:705-765`) so the carousel cards pick up the contour-stroke + depth-shadow filter chain + `.is-flipped-to-back` toggle for free — single SCSS source of truth across all 5 image-mode surfaces. Also added `data-arcana-key="{{ card.arcana }}"` + `data-image-url="{{ card.image_url|default:'' }}"` data-attrs to every fan-card so `StageCard.fromDataset` + `_setImageMode` flow w. no extra plumbing. (2) Game Kit carousel JS rewiring (`game-kit.js`): `_populateStage` now also calls `StageCard.populateStatExtras(stageBlock, card)` so the carousel stat block gets title + arcana + chip populated on every card focus (previously the stage block had only the keyword list; the call site simply wasn't wired). SPIN handler gates the 180° card rotation behind `!active.classList.contains('fan-card--image')` — for image-mode cards SPIN now just toggles `.is-reversed` on the stat block to swap EMANATION ↔ REVERSAL content w/o rotating the artwork (user-spec 2026-05-25 PM: "monodecks shouldn't have gravity and levity polarity"; image artwork is symmetric + shouldn't be inverted by a UI cycle). New `_flipToBack` helper mirrors the my_sign.html A.5-polish-2 FLIP-to-back animation (rotateY 0→90→0 over 500ms, `.is-flipped-to-back` toggle at 250ms midpoint, `data-flipping` cleared at 500ms); the existing `_flipActive` dispatches to it via `active.querySelector('.sig-stage-card-back-img')` presence check (the back-img element is only server-rendered for non-polarized image-equipped decks, so its presence is the gate). Polarized text-mode (Earthman) keeps the existing polarity-cycle FLIP. Per-card-change cleanup also clears `.is-flipped-to-back` on every card so a back-flipped card returns to front when it leaves focus (mirrors the SPIN reset semantics). (3) Top-left rank+suit chip retrofit (4 stat-block surfaces): the A.3 Q3 spec called for a chip but explicitly deferred to "Lower-priority follow-ups" in the project memory; user pulled it in this sprint as part of the carousel rewrite. New `.stat-face-header` flex wrapper holds the chip + EMANATION/REVERSAL label inline (chip is 2 rows tall, label is 1 — flex `align-items: flex-start` keeps them "vaguely inline" per spec). Chip mirrors the existing `.fan-card-corner` pattern: vertically stacked rank + suit-icon, no chrome (initial draft had a bordered pill — corrected per user clarification 2026-05-25 PM "vertically stacked, --secUser, in the top-left corner"). All 4 stat-block templates (my_sign.html / _applet-my-sign.html / _sea_stage.html / game_kit.html's `#id_fan_stage_block`) get the new header wrapper around their existing `.stat-face-label`. Applet renders the chip server-side from `card.corner_rank` + `card.suit_icon`; the other 3 surfaces leave the chip elements empty + populated by `StageCard.populateStatExtras` on each card focus (the helper now also walks `.stat-chip-rank` + `.stat-chip-icon` w. the same find-all + textContent / className pattern it already uses for title + arcana). Chip color is --secUser by default; polarity-aware overrides for surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block) flip the chip to --priUser for visibility — same logical inversion the keyword list rules already use. (4) Trump fa-hand-dots fallback in `TarotCard.suit_icon` — was reading the per-card `icon` field then returning `''` for any major arcana w/o an explicit override. Earthman's seed migration 0007 set `icon="fa-hand-dots"` on trumps 2+ as the universal trump symbol, but trumps 0/1 + every Minchiate trump fell through to empty + rendered the chip as just a number/numeral w. no icon below. Promoted the fallback into the model property (per-card override still wins via the `self.icon` branch), so every trump everywhere — chip, text-mode corner, future surfaces — gets a hand-with-dots glyph for free. Updated `TarotCardSuitIconTest.test_major_without_icon_returns_empty` → `test_major_without_icon_defaults_to_hand_dots`. (5) EMANATION/REVERSAL → --secUser (user-spec 2026-05-25 PM, mid-sprint): label color was --terUser (gold) across all 4 surfaces; flipped to --secUser everywhere so the label recedes against the title (gold/--quaUser per arcana stays the focal text). Default in the shared `stat-block-shared` mixin + applet bespoke `.stat-face-label` rule both updated. Per-polarity overrides: levity (bg --priUser) → label --secUser everywhere; gravity overrides preserved at --quiUser on the 3 surfaces whose gravity bg flips to --secUser (sig-stat-block / sea-stat-block / fan-stage-block — --secUser label would be invisible against --secUser bg, so --quiUser stays for contrast); applet gravity bg is --priUser (just full alpha vs. the default 0.8 — different from the other surfaces) so its gravity override removed entirely, label uses the shared --secUser default in both polarities. User-confirmed visually 2026-05-25 PM: applet EMANATION now in --secUser (`rgb(162, 170, 173)`) matching the chip color — chip + label read as a coordinated header pair rather than competing w. the title. Tests: 1314/1314 IT+UT total green (76s; +8 new in this sprint — 4 chip-presence ITs across the 4 stat-block surfaces, 3 _tarot_fan image-mode-branch ITs covering image-equipped + text-mode + polarized-image-equipped permutations, 1 UT-rename for the trump fa-hand-dots default). Surfaces NOT covered by ITs: SCSS layout (visual-only — verified live via Claudezilla on /gameboard/game-kit/ Minchiate carousel, /billboard/my-sign/ stage card, /billboard/ applet preview); JS-side chip-fill via populateStatExtras (covered transitively by the populateStatExtras existing call sites — no new test for the chip-specific code path since the test surface for stage-card.js is currently Jasmine-only via FanStageSpec.js, deferred). No new FT runs per [[feedback-ft-run-discipline]] — all changes are template / SCSS / JS / model property; IT coverage is comprehensive for the server-rendered surfaces + the visual verify covered the JS-populated surfaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:25:41 -04:00
return 'fa-hand-dots'
return {
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
self.BRANDS: 'fa-wand-sparkles',
self.CROWNS: 'fa-crown',
self.GRAILS: 'fa-trophy',
self.BLADES: 'fa-gun',
}.get(self.suit, '')
A.2 TarotCard.image_filename + display_suit_name properties — TDD. Sprint A.2 of [[project-image-based-deck-face-rendering]]. Adds two per-card derived properties that consume the new `DeckVariant.family` field (locked in A.0) to translate canonical-Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES) into family-authentic filename slugs + UI labels per [[reference-card-image-naming-convention]] v2. `DeckVariant` gains the family-mapping tables + methods (`suit_slug` / `suit_display` / `trump_category`); `TarotCard` consumes them via `image_filename` + `display_suit_name`. Two mapping tables live on DeckVariant (single source of truth for per-family vocab): `_SUIT_SLUG_BY_FAMILY` (4 families × 4 suits = 16 entries: earthman is identity-mapped {BRANDS→brands, CROWNS→crowns, GRAILS→grails, BLADES→blades}; italian is {BRANDS→batons, CROWNS→coins, GRAILS→cups, BLADES→swords}; english is {BRANDS→wands, CROWNS→pentacles, GRAILS→cups, BLADES→swords}; playing is {BRANDS→clubs, CROWNS→diamonds, GRAILS→hearts, BLADES→spades}) and `_TRUMP_CATEGORY_BY_FAMILY` (earthman+italian use "trumps", english uses "majors" matching Modern Tarot's "Major Arcana", playing is None since 52-card decks have no trump category — jokers handled separately when a playing deck is seeded). `DeckVariant.suit_slug(canonical)` returns the filename slug; `suit_display(canonical)` returns capitalized UI label (via slug.capitalize()); `trump_category` is a property since it takes no per-card argument. `TarotCard.image_filename` branches on arcana: MAJOR returns `<deck-slug>-<trump-category>-<NN>-<card-slug>.png` (NN = zero-padded number per v2 convention, e.g. 00 for Il Matto; card-slug carries the italian name like "il-gobbo" or english like "the-fool"); MINOR/MIDDLE returns `<deck-slug>-<suit-slug>-<NN>[-<court>].png` where court suffix is "page"/"knight"/"queen"/"king" for ranks 11-14 (tarot family courts; playing-family's 3-court jack/queen/king deferred to playing-deck-seed sprint). `display_suit_name` returns capitalized family-authentic suit name ("Batons" for italian BRANDS, "Pentacles" for english CROWNS) or empty string for major arcana (no suit). Both properties are pure-derived — no schema migration needed, no DB writes; the template (Sprint A.3+) decides whether to render <img src=image_filename> based on `deck.has_card_images`. RWS deck's image_filename returns a path even though has_card_images=False (path is correct per convention; just no file exists at that path yet — once RWS images are sourced, flip the flag). 17 new ITs in `CardImageFilenameA2Test` cover: Minchiate trumps (Il Matto rank-00, Il Gobbo rank-11, Le Trombe rank-40, L'Acqua rank-21 w. apostrophe-restored slug); Minchiate minors (Ace of Batons pip-with-no-court-suffix, Ten of Coins, Page of Cups w. court suffix, King of Swords); RWS post-revocab (Ace of Cups uses english-family "cups" slug despite suit=GRAILS, The Fool uses "majors" category, King of Pentacles uses "pentacles" slug despite suit=CROWNS); Earthman identity-mapped (BRANDS→brands); display_suit_name across all 3 tarot families (italian BRANDS→"Batons", italian CROWNS→"Coins", english CROWNS→"Pentacles", earthman BRANDS→"Brands"); empty for majors. Tests: 17 new green; 1289/1289 IT+UT total green (63s; +17 from A.1's 1272). Out of scope: A.3 wires my_sign.html's first render branch (the visible-win first surface); A.4 builds card-deck icon + game_kit applet; A.5-A.8 DRY across my_sea + both billboard applets + room Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:37:16 -04:00
# Tarot-family courts: rank 11=page, 12=knight, 13=queen, 14=king. Playing
# family (3 courts: jack/queen/king at ranks 11-13) handled separately when
# a playing deck is seeded — Sprint A.2 covers tarot families only.
_COURT_NAME_BY_RANK = {11: "page", 12: "knight", 13: "queen", 14: "king"}
@property
def image_filename(self):
"""v2-convention filename per [[reference-card-image-naming-convention]].
Always derives a path; the template decides whether to actually render
an <img> based on `deck_variant.has_card_images`."""
deck = self.deck_variant
if self.arcana == self.MAJOR:
return f"{deck.slug}-{deck.trump_category}-{self.number:02d}-{self.slug}.png"
# MINOR or MIDDLE: <deck-slug>-<suit-slug>-<NN>[-<court>].png
suit_slug = deck.suit_slug(self.suit)
rank = f"{self.number:02d}"
court = self._COURT_NAME_BY_RANK.get(self.number)
if court:
return f"{deck.slug}-{suit_slug}-{rank}-{court}.png"
return f"{deck.slug}-{suit_slug}-{rank}.png"
@property
def display_suit_name(self):
"""Family-authentic capitalized suit label (e.g. 'Batons' for italian
BRANDS, 'Pentacles' for english CROWNS). Empty for major arcana."""
if not self.suit:
return ""
return self.deck_variant.suit_display(self.suit)
A.3 my_sign.html image-rendering — first visible surface — TDD. Sprint A.3 of [[project-image-based-deck-face-rendering]]. When the user's equipped deck has `has_card_images=True` (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card on /billboard/my-sign/ renders as an <img> over the irregular-shape transparent PNG with a contour-following arcana-colored stroke — not the text fan-card scaffold. First of 6 surfaces in the image-rendering rollout (my_sea + both billboard applets + room + game_kit follow in A.5+). New `TarotCard.image_url` property (consumes A.2's image_filename + DeckVariant.has_card_images + django.templatetags.static.static() to produce a full static-asset URL) — empty string when has_card_images=False so legacy text-only decks (Earthman, RWS) pass through transparently. `my_sign.html` picker grid `.sig-card` elements gain `data-image-url` + `data-arcana-key` attrs (the latter for stroke-color CSS selection); the `.sig-stage-card` scaffold gains a hidden `<img class="sig-stage-card-img">` slot that JS swaps visible when image-mode is active. `stage-card.js` extends `fromDataset` to read image_url + arcana_key; new `_setImageMode(stageCard, card)` toggles the `.sig-stage-card--image` marker class + sets `data-arcana-key` on the stage card + populates the img src/alt; called from `populateCard` so all existing sig-stage flows pick up image rendering automatically (text-mode decks still pass through since image_url is empty). SCSS: new `.sig-stage-card.sig-stage-card--image` rule hides the `.fan-card-corner` + `.fan-card-face` text scaffold, strips the rectangular border/padding, and applies a 4-cardinal-direction `filter: drop-shadow()` stack to the `<img>` so the stroke FOLLOWS the alpha contour of the PNG instead of tracing a rectangular bounding box (per user spec 2026-05-25 PM clarification — early draft used a rectangular border which doesn't match the irregular-card aesthetic). Stroke color is driven by a CSS custom prop `--img-stroke-color` defaulting to `rgba(var(--quiUser), 1)` (cream — minor + middle arcana); `[data-arcana-key="MAJOR"]` override flips it to `rgba(var(--terUser), 1)` (gold) per Q2 lock. mobile-safe — filter on raster images works cross-browser (the [[feedback-mobile-svg-glow]] dead-end was specifically SVG glow, not raster drop-shadows). New `_seed_minchiate_image_fixtures()` helper in `functional_tests/sig_page.py` re-seeds the minimal Minchiate fixture (DeckVariant + Il Matto + Papa Uno) needed for image FTs after TransactionTestCase's flush wipes migration data — mirrors the existing `_seed_earthman_sig_pile` pattern per [[feedback-transactiontestcase-flush]]. New `MySignImageRenderingTest.test_saved_sig_renders_as_img_for_image_deck` FT seeds Minchiate + creates a superuser test gamer (superuser auto-gets super-nomad + super-schizo Notes via the User post_save signal, which `_filter_major_unlocks` then lets through to expose Il Matto in the picker grid — otherwise Minchiate's sig pool is empty since it has no MIDDLE arcana cards), equips Minchiate, saves Il Matto as sig, visits /billboard/my-sign/, asserts the stage card displays + contains an <img> w. src ending in the v2-convention filename `minchiate-fiorentine-1860-1890-trumps-00-il-matto.png` + carries `.sig-stage-card--image` marker class. Out of scope for this commit (deferred to A.3 follow-up polish + A.5+): the full stat-block restructure (top-left rank+suit chip Q♥ inline w. EMANATION/REVERSAL header; title in arcana-color font; keyword reposition; FYI panel re-anchor — per the locked Q3 spec) — image card-face ships now w. the existing stat-block layout to land the visible-win first. Tests: 1 new FT green; 15/15 my_sign FT class green (no regression on the 14 existing tests); 1289/1289 IT+UT total green (68s, unchanged from A.2 since no new ITs in this commit — FT covers the wiring end-to-end). Sprint A backend foundation (A.0+A.1+A.2) + first visible surface (A.3) all landed; 5 surfaces remain (A.5-A.8 + A.4's card-deck icon) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:04:18 -04:00
@property
def image_url(self):
"""Full static-asset URL for the card image, or empty string if the
deck has no images (legacy text-only mode). Constructed via Django's
`static` helper so STATIC_URL prefix + manifest-versioning (when
image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd) Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision. **(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26: - earthman family → "default" (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration) - slug startswith "tarot-" → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english) - otherwise → uses slug as-is (italian/minchiate-fiorentine-1860-1890) Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`). Why this naming over alternatives the user considered: - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition) - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks) - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum) No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered. **(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries. **(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight). Filename normalization via one-shot `d:/tmp/rename_rws.py`: - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png` - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping) - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`) - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both) - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png` Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file. pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB. Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh. Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00
WhiteNoise compressed manifest is active) flow through.
Path structure: `cards-faces/<family>/<variant_dir_slug>/<filename>`
per the family-grouped tree convention (user spec 2026-05-26). See
`DeckVariant.variant_dir_slug` for the variant subdir mapping.
"""
A.3 my_sign.html image-rendering — first visible surface — TDD. Sprint A.3 of [[project-image-based-deck-face-rendering]]. When the user's equipped deck has `has_card_images=True` (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card on /billboard/my-sign/ renders as an <img> over the irregular-shape transparent PNG with a contour-following arcana-colored stroke — not the text fan-card scaffold. First of 6 surfaces in the image-rendering rollout (my_sea + both billboard applets + room + game_kit follow in A.5+). New `TarotCard.image_url` property (consumes A.2's image_filename + DeckVariant.has_card_images + django.templatetags.static.static() to produce a full static-asset URL) — empty string when has_card_images=False so legacy text-only decks (Earthman, RWS) pass through transparently. `my_sign.html` picker grid `.sig-card` elements gain `data-image-url` + `data-arcana-key` attrs (the latter for stroke-color CSS selection); the `.sig-stage-card` scaffold gains a hidden `<img class="sig-stage-card-img">` slot that JS swaps visible when image-mode is active. `stage-card.js` extends `fromDataset` to read image_url + arcana_key; new `_setImageMode(stageCard, card)` toggles the `.sig-stage-card--image` marker class + sets `data-arcana-key` on the stage card + populates the img src/alt; called from `populateCard` so all existing sig-stage flows pick up image rendering automatically (text-mode decks still pass through since image_url is empty). SCSS: new `.sig-stage-card.sig-stage-card--image` rule hides the `.fan-card-corner` + `.fan-card-face` text scaffold, strips the rectangular border/padding, and applies a 4-cardinal-direction `filter: drop-shadow()` stack to the `<img>` so the stroke FOLLOWS the alpha contour of the PNG instead of tracing a rectangular bounding box (per user spec 2026-05-25 PM clarification — early draft used a rectangular border which doesn't match the irregular-card aesthetic). Stroke color is driven by a CSS custom prop `--img-stroke-color` defaulting to `rgba(var(--quiUser), 1)` (cream — minor + middle arcana); `[data-arcana-key="MAJOR"]` override flips it to `rgba(var(--terUser), 1)` (gold) per Q2 lock. mobile-safe — filter on raster images works cross-browser (the [[feedback-mobile-svg-glow]] dead-end was specifically SVG glow, not raster drop-shadows). New `_seed_minchiate_image_fixtures()` helper in `functional_tests/sig_page.py` re-seeds the minimal Minchiate fixture (DeckVariant + Il Matto + Papa Uno) needed for image FTs after TransactionTestCase's flush wipes migration data — mirrors the existing `_seed_earthman_sig_pile` pattern per [[feedback-transactiontestcase-flush]]. New `MySignImageRenderingTest.test_saved_sig_renders_as_img_for_image_deck` FT seeds Minchiate + creates a superuser test gamer (superuser auto-gets super-nomad + super-schizo Notes via the User post_save signal, which `_filter_major_unlocks` then lets through to expose Il Matto in the picker grid — otherwise Minchiate's sig pool is empty since it has no MIDDLE arcana cards), equips Minchiate, saves Il Matto as sig, visits /billboard/my-sign/, asserts the stage card displays + contains an <img> w. src ending in the v2-convention filename `minchiate-fiorentine-1860-1890-trumps-00-il-matto.png` + carries `.sig-stage-card--image` marker class. Out of scope for this commit (deferred to A.3 follow-up polish + A.5+): the full stat-block restructure (top-left rank+suit chip Q♥ inline w. EMANATION/REVERSAL header; title in arcana-color font; keyword reposition; FYI panel re-anchor — per the locked Q3 spec) — image card-face ships now w. the existing stat-block layout to land the visible-win first. Tests: 1 new FT green; 15/15 my_sign FT class green (no regression on the 14 existing tests); 1289/1289 IT+UT total green (68s, unchanged from A.2 since no new ITs in this commit — FT covers the wiring end-to-end). Sprint A backend foundation (A.0+A.1+A.2) + first visible surface (A.3) all landed; 5 surfaces remain (A.5-A.8 + A.4's card-deck icon) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:04:18 -04:00
if not self.deck_variant.has_card_images:
return ""
from django.templatetags.static import static
image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd) Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision. **(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26: - earthman family → "default" (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration) - slug startswith "tarot-" → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english) - otherwise → uses slug as-is (italian/minchiate-fiorentine-1860-1890) Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`). Why this naming over alternatives the user considered: - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition) - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks) - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum) No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered. **(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries. **(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight). Filename normalization via one-shot `d:/tmp/rename_rws.py`: - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png` - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping) - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`) - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both) - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png` Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file. pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB. Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh. Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00
deck = self.deck_variant
A.3 my_sign.html image-rendering — first visible surface — TDD. Sprint A.3 of [[project-image-based-deck-face-rendering]]. When the user's equipped deck has `has_card_images=True` (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card on /billboard/my-sign/ renders as an <img> over the irregular-shape transparent PNG with a contour-following arcana-colored stroke — not the text fan-card scaffold. First of 6 surfaces in the image-rendering rollout (my_sea + both billboard applets + room + game_kit follow in A.5+). New `TarotCard.image_url` property (consumes A.2's image_filename + DeckVariant.has_card_images + django.templatetags.static.static() to produce a full static-asset URL) — empty string when has_card_images=False so legacy text-only decks (Earthman, RWS) pass through transparently. `my_sign.html` picker grid `.sig-card` elements gain `data-image-url` + `data-arcana-key` attrs (the latter for stroke-color CSS selection); the `.sig-stage-card` scaffold gains a hidden `<img class="sig-stage-card-img">` slot that JS swaps visible when image-mode is active. `stage-card.js` extends `fromDataset` to read image_url + arcana_key; new `_setImageMode(stageCard, card)` toggles the `.sig-stage-card--image` marker class + sets `data-arcana-key` on the stage card + populates the img src/alt; called from `populateCard` so all existing sig-stage flows pick up image rendering automatically (text-mode decks still pass through since image_url is empty). SCSS: new `.sig-stage-card.sig-stage-card--image` rule hides the `.fan-card-corner` + `.fan-card-face` text scaffold, strips the rectangular border/padding, and applies a 4-cardinal-direction `filter: drop-shadow()` stack to the `<img>` so the stroke FOLLOWS the alpha contour of the PNG instead of tracing a rectangular bounding box (per user spec 2026-05-25 PM clarification — early draft used a rectangular border which doesn't match the irregular-card aesthetic). Stroke color is driven by a CSS custom prop `--img-stroke-color` defaulting to `rgba(var(--quiUser), 1)` (cream — minor + middle arcana); `[data-arcana-key="MAJOR"]` override flips it to `rgba(var(--terUser), 1)` (gold) per Q2 lock. mobile-safe — filter on raster images works cross-browser (the [[feedback-mobile-svg-glow]] dead-end was specifically SVG glow, not raster drop-shadows). New `_seed_minchiate_image_fixtures()` helper in `functional_tests/sig_page.py` re-seeds the minimal Minchiate fixture (DeckVariant + Il Matto + Papa Uno) needed for image FTs after TransactionTestCase's flush wipes migration data — mirrors the existing `_seed_earthman_sig_pile` pattern per [[feedback-transactiontestcase-flush]]. New `MySignImageRenderingTest.test_saved_sig_renders_as_img_for_image_deck` FT seeds Minchiate + creates a superuser test gamer (superuser auto-gets super-nomad + super-schizo Notes via the User post_save signal, which `_filter_major_unlocks` then lets through to expose Il Matto in the picker grid — otherwise Minchiate's sig pool is empty since it has no MIDDLE arcana cards), equips Minchiate, saves Il Matto as sig, visits /billboard/my-sign/, asserts the stage card displays + contains an <img> w. src ending in the v2-convention filename `minchiate-fiorentine-1860-1890-trumps-00-il-matto.png` + carries `.sig-stage-card--image` marker class. Out of scope for this commit (deferred to A.3 follow-up polish + A.5+): the full stat-block restructure (top-left rank+suit chip Q♥ inline w. EMANATION/REVERSAL header; title in arcana-color font; keyword reposition; FYI panel re-anchor — per the locked Q3 spec) — image card-face ships now w. the existing stat-block layout to land the visible-win first. Tests: 1 new FT green; 15/15 my_sign FT class green (no regression on the 14 existing tests); 1289/1289 IT+UT total green (68s, unchanged from A.2 since no new ITs in this commit — FT covers the wiring end-to-end). Sprint A backend foundation (A.0+A.1+A.2) + first visible surface (A.3) all landed; 5 surfaces remain (A.5-A.8 + A.4's card-deck icon) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:04:18 -04:00
return static(
image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd) Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision. **(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26: - earthman family → "default" (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration) - slug startswith "tarot-" → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english) - otherwise → uses slug as-is (italian/minchiate-fiorentine-1860-1890) Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`). Why this naming over alternatives the user considered: - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition) - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks) - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum) No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered. **(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries. **(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight). Filename normalization via one-shot `d:/tmp/rename_rws.py`: - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png` - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping) - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`) - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both) - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png` Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file. pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB. Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh. Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants). Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00
f"apps/epic/images/cards-faces/{deck.family}/{deck.variant_dir_slug}/{self.image_filename}"
A.3 my_sign.html image-rendering — first visible surface — TDD. Sprint A.3 of [[project-image-based-deck-face-rendering]]. When the user's equipped deck has `has_card_images=True` (Minchiate Fiorentine 1860-1890 today), the saved-sig stage card on /billboard/my-sign/ renders as an <img> over the irregular-shape transparent PNG with a contour-following arcana-colored stroke — not the text fan-card scaffold. First of 6 surfaces in the image-rendering rollout (my_sea + both billboard applets + room + game_kit follow in A.5+). New `TarotCard.image_url` property (consumes A.2's image_filename + DeckVariant.has_card_images + django.templatetags.static.static() to produce a full static-asset URL) — empty string when has_card_images=False so legacy text-only decks (Earthman, RWS) pass through transparently. `my_sign.html` picker grid `.sig-card` elements gain `data-image-url` + `data-arcana-key` attrs (the latter for stroke-color CSS selection); the `.sig-stage-card` scaffold gains a hidden `<img class="sig-stage-card-img">` slot that JS swaps visible when image-mode is active. `stage-card.js` extends `fromDataset` to read image_url + arcana_key; new `_setImageMode(stageCard, card)` toggles the `.sig-stage-card--image` marker class + sets `data-arcana-key` on the stage card + populates the img src/alt; called from `populateCard` so all existing sig-stage flows pick up image rendering automatically (text-mode decks still pass through since image_url is empty). SCSS: new `.sig-stage-card.sig-stage-card--image` rule hides the `.fan-card-corner` + `.fan-card-face` text scaffold, strips the rectangular border/padding, and applies a 4-cardinal-direction `filter: drop-shadow()` stack to the `<img>` so the stroke FOLLOWS the alpha contour of the PNG instead of tracing a rectangular bounding box (per user spec 2026-05-25 PM clarification — early draft used a rectangular border which doesn't match the irregular-card aesthetic). Stroke color is driven by a CSS custom prop `--img-stroke-color` defaulting to `rgba(var(--quiUser), 1)` (cream — minor + middle arcana); `[data-arcana-key="MAJOR"]` override flips it to `rgba(var(--terUser), 1)` (gold) per Q2 lock. mobile-safe — filter on raster images works cross-browser (the [[feedback-mobile-svg-glow]] dead-end was specifically SVG glow, not raster drop-shadows). New `_seed_minchiate_image_fixtures()` helper in `functional_tests/sig_page.py` re-seeds the minimal Minchiate fixture (DeckVariant + Il Matto + Papa Uno) needed for image FTs after TransactionTestCase's flush wipes migration data — mirrors the existing `_seed_earthman_sig_pile` pattern per [[feedback-transactiontestcase-flush]]. New `MySignImageRenderingTest.test_saved_sig_renders_as_img_for_image_deck` FT seeds Minchiate + creates a superuser test gamer (superuser auto-gets super-nomad + super-schizo Notes via the User post_save signal, which `_filter_major_unlocks` then lets through to expose Il Matto in the picker grid — otherwise Minchiate's sig pool is empty since it has no MIDDLE arcana cards), equips Minchiate, saves Il Matto as sig, visits /billboard/my-sign/, asserts the stage card displays + contains an <img> w. src ending in the v2-convention filename `minchiate-fiorentine-1860-1890-trumps-00-il-matto.png` + carries `.sig-stage-card--image` marker class. Out of scope for this commit (deferred to A.3 follow-up polish + A.5+): the full stat-block restructure (top-left rank+suit chip Q♥ inline w. EMANATION/REVERSAL header; title in arcana-color font; keyword reposition; FYI panel re-anchor — per the locked Q3 spec) — image card-face ships now w. the existing stat-block layout to land the visible-win first. Tests: 1 new FT green; 15/15 my_sign FT class green (no regression on the 14 existing tests); 1289/1289 IT+UT total green (68s, unchanged from A.2 since no new ITs in this commit — FT covers the wiring end-to-end). Sprint A backend foundation (A.0+A.1+A.2) + first visible surface (A.3) all landed; 5 surfaces remain (A.5-A.8 + A.4's card-deck icon) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:04:18 -04:00
)
@property
def cautions_json(self):
import json
return json.dumps(self.cautions)
SIG SELECT sprint 1+2: SPIN animation; Emanation/Reversal; Ally Interaction FYI — TDD Sprint 1 (template + SCSS): - Stage card gains .fan-card-reversal-name + .fan-card-reversal-qualifier elements (pre-rotated 180° so they read forward after card spins); sig cards gain data-reversal attr - _card-deck.scss: Z-axis rotate(180deg) spin on .stage-card--reversed; reversed elements dim @ opacity 0.25 normally, flip to 1 when card is spun; upright content dims in return - Stat face labels: Upright→Emanation, Reversed→Reversal - Fixture updated: Emanation/Reversal labels; reversal elements + data-reversal attr Sprint 2 (FYI from mechanisms + articulations): - sig-select.js: _openCaution() now parses data-mechanisms + data-articulations (concat) instead of data-cautions; _renderCaution() sets .sig-caution-title from entry.category, .sig-caution-effect.innerHTML from entry.effect; empty fallback: "No ally interactions" - TarotCard model: mechanisms_json + articulations_json @property (parallel to cautions_json) - Template: data-cautions→data-mechanisms+data-articulations; "Caution!"→"" title (set by JS); "Rival Interaction"→"Ally Interaction"; shoptalk <p> removed - SigSelectSpec.js: all old caution tests migrated to {category,effect} dict format + data-mechanisms; 7-spec "FYI from mechanisms + articulations" describe block; 242 specs green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:18:16 -04:00
@property
def energies_json(self):
SIG SELECT sprint 1+2: SPIN animation; Emanation/Reversal; Ally Interaction FYI — TDD Sprint 1 (template + SCSS): - Stage card gains .fan-card-reversal-name + .fan-card-reversal-qualifier elements (pre-rotated 180° so they read forward after card spins); sig cards gain data-reversal attr - _card-deck.scss: Z-axis rotate(180deg) spin on .stage-card--reversed; reversed elements dim @ opacity 0.25 normally, flip to 1 when card is spun; upright content dims in return - Stat face labels: Upright→Emanation, Reversed→Reversal - Fixture updated: Emanation/Reversal labels; reversal elements + data-reversal attr Sprint 2 (FYI from mechanisms + articulations): - sig-select.js: _openCaution() now parses data-mechanisms + data-articulations (concat) instead of data-cautions; _renderCaution() sets .sig-caution-title from entry.category, .sig-caution-effect.innerHTML from entry.effect; empty fallback: "No ally interactions" - TarotCard model: mechanisms_json + articulations_json @property (parallel to cautions_json) - Template: data-cautions→data-mechanisms+data-articulations; "Caution!"→"" title (set by JS); "Rival Interaction"→"Ally Interaction"; shoptalk <p> removed - SigSelectSpec.js: all old caution tests migrated to {category,effect} dict format + data-mechanisms; 7-spec "FYI from mechanisms + articulations" describe block; 242 specs green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:18:16 -04:00
import json
return json.dumps(self.energies)
SIG SELECT sprint 1+2: SPIN animation; Emanation/Reversal; Ally Interaction FYI — TDD Sprint 1 (template + SCSS): - Stage card gains .fan-card-reversal-name + .fan-card-reversal-qualifier elements (pre-rotated 180° so they read forward after card spins); sig cards gain data-reversal attr - _card-deck.scss: Z-axis rotate(180deg) spin on .stage-card--reversed; reversed elements dim @ opacity 0.25 normally, flip to 1 when card is spun; upright content dims in return - Stat face labels: Upright→Emanation, Reversed→Reversal - Fixture updated: Emanation/Reversal labels; reversal elements + data-reversal attr Sprint 2 (FYI from mechanisms + articulations): - sig-select.js: _openCaution() now parses data-mechanisms + data-articulations (concat) instead of data-cautions; _renderCaution() sets .sig-caution-title from entry.category, .sig-caution-effect.innerHTML from entry.effect; empty fallback: "No ally interactions" - TarotCard model: mechanisms_json + articulations_json @property (parallel to cautions_json) - Template: data-cautions→data-mechanisms+data-articulations; "Caution!"→"" title (set by JS); "Rival Interaction"→"Ally Interaction"; shoptalk <p> removed - SigSelectSpec.js: all old caution tests migrated to {category,effect} dict format + data-mechanisms; 7-spec "FYI from mechanisms + articulations" describe block; 242 specs green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:18:16 -04:00
@property
def operations_json(self):
SIG SELECT sprint 1+2: SPIN animation; Emanation/Reversal; Ally Interaction FYI — TDD Sprint 1 (template + SCSS): - Stage card gains .fan-card-reversal-name + .fan-card-reversal-qualifier elements (pre-rotated 180° so they read forward after card spins); sig cards gain data-reversal attr - _card-deck.scss: Z-axis rotate(180deg) spin on .stage-card--reversed; reversed elements dim @ opacity 0.25 normally, flip to 1 when card is spun; upright content dims in return - Stat face labels: Upright→Emanation, Reversed→Reversal - Fixture updated: Emanation/Reversal labels; reversal elements + data-reversal attr Sprint 2 (FYI from mechanisms + articulations): - sig-select.js: _openCaution() now parses data-mechanisms + data-articulations (concat) instead of data-cautions; _renderCaution() sets .sig-caution-title from entry.category, .sig-caution-effect.innerHTML from entry.effect; empty fallback: "No ally interactions" - TarotCard model: mechanisms_json + articulations_json @property (parallel to cautions_json) - Template: data-cautions→data-mechanisms+data-articulations; "Caution!"→"" title (set by JS); "Rival Interaction"→"Ally Interaction"; shoptalk <p> removed - SigSelectSpec.js: all old caution tests migrated to {category,effect} dict format + data-mechanisms; 7-spec "FYI from mechanisms + articulations" describe block; 242 specs green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:18:16 -04:00
import json
return json.dumps(self.operations)
SIG SELECT sprint 1+2: SPIN animation; Emanation/Reversal; Ally Interaction FYI — TDD Sprint 1 (template + SCSS): - Stage card gains .fan-card-reversal-name + .fan-card-reversal-qualifier elements (pre-rotated 180° so they read forward after card spins); sig cards gain data-reversal attr - _card-deck.scss: Z-axis rotate(180deg) spin on .stage-card--reversed; reversed elements dim @ opacity 0.25 normally, flip to 1 when card is spun; upright content dims in return - Stat face labels: Upright→Emanation, Reversed→Reversal - Fixture updated: Emanation/Reversal labels; reversal elements + data-reversal attr Sprint 2 (FYI from mechanisms + articulations): - sig-select.js: _openCaution() now parses data-mechanisms + data-articulations (concat) instead of data-cautions; _renderCaution() sets .sig-caution-title from entry.category, .sig-caution-effect.innerHTML from entry.effect; empty fallback: "No ally interactions" - TarotCard model: mechanisms_json + articulations_json @property (parallel to cautions_json) - Template: data-cautions→data-mechanisms+data-articulations; "Caution!"→"" title (set by JS); "Rival Interaction"→"Ally Interaction"; shoptalk <p> removed - SigSelectSpec.js: all old caution tests migrated to {category,effect} dict format + data-mechanisms; 7-spec "FYI from mechanisms + articulations" describe block; 242 specs green Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 17:18:16 -04:00
def __str__(self):
return self.name
class TarotDeck(models.Model):
"""One shuffled deck per room, scoped to the founder's chosen DeckVariant."""
room = models.OneToOneField(Room, on_delete=models.CASCADE, related_name="tarot_deck")
deck_variant = models.ForeignKey(
DeckVariant, null=True, blank=True,
on_delete=models.SET_NULL, related_name="active_decks",
)
drawn_card_ids = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
@property
def remaining_count(self):
total = self.deck_variant.card_count if self.deck_variant else 0
return total - len(self.drawn_card_ids)
def draw(self, n=1):
"""Draw n cards at random. Returns list of (TarotCard, reversed: bool) tuples."""
available = list(
TarotCard.objects.filter(deck_variant=self.deck_variant)
.exclude(id__in=self.drawn_card_ids)
)
if len(available) < n:
raise ValueError(
f"Not enough cards remaining: {len(available)} available, {n} requested"
)
drawn = random.sample(available, n)
self.drawn_card_ids = self.drawn_card_ids + [card.id for card in drawn]
self.save(update_fields=["drawn_card_ids"])
return [(card, random.choice([True, False])) for card in drawn]
def shuffle(self):
"""Reset the deck so all variant cards are available again."""
self.drawn_card_ids = []
self.save(update_fields=["drawn_card_ids"])
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
class SigReservation(models.Model):
LEVITY = 'levity'
GRAVITY = 'gravity'
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
)
seat = models.ForeignKey(
'TableSeat', null=True, blank=True,
on_delete=models.SET_NULL, related_name='sig_reservation',
)
card = models.ForeignKey(
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
)
role = models.CharField(max_length=2)
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
reserved_at = models.DateTimeField(auto_now_add=True)
ready = models.BooleanField(default=False)
countdown_remaining = models.IntegerField(null=True, blank=True)
class Meta:
constraints = [
UniqueConstraint(
fields=['room', 'gamer'],
name='one_sig_reservation_per_gamer_per_room',
),
UniqueConstraint(
fields=['room', 'card', 'polarity'],
name='one_reservation_per_card_per_polarity_per_room',
),
]
# ── Significator deck helpers ─────────────────────────────────────────────────
def _room_deck_variant(room):
"""Return the DeckVariant in use for this room.
Looks up the deck committed to any TableSeat in the room (all seats share the
same deck per game). Falls back to the room owner's equipped_deck for rooms
created before deck contribution was wired.
"""
seat = room.table_seats.filter(deck_variant__isnull=False).first()
if seat:
return seat.deck_variant
return room.owner.equipped_deck
def sig_deck_cards(room):
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
PC/BC pair BRANDS + CROWNS Middle Arcana court cards (1114): 8 unique
SC/AC pair BLADES + GRAILS Middle Arcana court cards (1114): 8 unique
NC/EC pair MAJOR arcana numbers 0 and 1: 2 unique
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
"""
A.0 image-rendering schema + RWS rename + canonical-Earthman suit collapse — TDD. Sprint A.0 of [[project-image-based-deck-face-rendering]]. Adds three `DeckVariant` fields: `has_card_images` (BooleanField default=True — Earthman keeps False until its artwork ships, every new deck defaults True), `family` (CharField choices=[earthman, italian, english, playing] default=earthman — drives per-family display + filename slug mapping per [[reference-card-image-naming-convention]]), `is_polarized` (BooleanField default=False — Earthman is True today; Sprint A.4 game_kit applet will render "(×2)" in --terUser for polarized decks; Sprint C+B segment model uses it for segment-count logic). `TarotCard.SUIT_CHOICES` collapses from 8 values to 4 canonical Earthman values (BRANDS / CROWNS / GRAILS / BLADES); WANDS / CUPS / SWORDS / PENTACLES dropped — they were duplicative at the structural level since `sig_deck_cards` + `levity/gravity_sig_cards` already treated [WANDS, BRANDS, CROWNS] as one segment and [SWORDS, BLADES, CUPS, GRAILS] as another (so the project already *functionally* equated them; the lock just makes that explicit). Per-family display vocab (`batons` for Italian, `wands` for English, `clubs` for Playing) lives in Sprint A.2's `display_suit_name` property, not in the enum. Audit 2026-05-25 revealed the existing `fiorentine-minchiate` DeckVariant is actually 78-card RWS Tarot in disguise (22 majors numbered 0-21 w. RWS names: The Fool / The Magician / ... / The World; 56 minors in 4 suits × 14 cards) — NOT Minchiate (which has 40 trumps + 1 Il Matto + 56 minors = 97 cards). Migration 0012 renames the slug → `tarot-rider-waite-smith`, name → "Tarot (Rider-Waite-Smith)", sets family='english', has_card_images=False, is_polarized=False — and revocabs its 56 minor cards' suits in-place (WANDS→BRANDS, CUPS→GRAILS, SWORDS→BLADES, PENTACLES→CROWNS) so they match the new canonical enum. FKs (User.equipped_deck, User.unlocked_decks, TableSeat.deck_variant, etc.) survive untouched — slug-only changes don't break referential integrity. Earthman fields set explicitly in 0012 too (family=earthman, has_card_images=False, is_polarized=True). Companion code simplifications: `sig_deck_cards` + `_sig_unique_cards_for_deck` queries shrink from `suit__in=[3 values]` and `[4 values]` to `[2 values]` each (one per segment); `TarotCard.suit_icon` mapping shrinks from 8 entries to 4; `gameboard.views.tarot_fan._suit_order` shrinks from 8 keys to 4. Existing test files updated: `test_game_room_tray.py` (largest update — `self.fiorentine` → `self.rws`, `id_kit_fiorentine_deck` → `id_kit_tarot_deck` (template-id derives from deck.short_key = first slug segment), assertion "Fiorentine" → "Rider-Waite-Smith"); `test_game_room_deck_contrib.py` (same pattern, smaller); `lyric/test_models.py` + `gameboard/test_views.py` (slug literal swaps only); `epic/test_models.py` `_make_sig_card` test fixtures: "WANDS"→"BRANDS", "CUPS"→"GRAILS". 14 new ITs in `DeckSchemaA0Test` cover the schema additions + migration outcomes (field existence + choice values + earthman has all three fields set correctly + RWS rename verified + RWS cards use canonical suits + dropped enum values absent from SUIT_CHOICES). Tests: 14 new green; 1255/1255 IT+UT total green (38s); no regressions. Out of scope: Sprint A.1 will seed the actual Minchiate Fiorentine 1860-1890 (97-card) DeckVariant + TarotCard rows w. family='italian', has_card_images=True; A.2 adds the `image_filename` + `display_suit_name` properties that consume the new `family` field; A.3+ wires the render branches across 6 surfaces Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 23:25:26 -04:00
unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room))
return unique_cards + unique_cards # × 2 = 36
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
def _sig_unique_cards_for_deck(deck_variant):
"""Return the 18 unique TarotCards forming one sig pile for the given
deck variant. Shared between room sig-select (called via _sig_unique_cards
A.3-polish: cross-deck sig picker (MINOR + MIDDLE courts) + My Sea applet sig-decoupling — TDD. Two user-reported bugs caught during A.3 visual verify (2026-05-25 PM). Bug 1: my_sign picker shows only 2 cards (Major 0 + 1) for Minchiate-equipped users since `_sig_unique_cards_for_deck` filters by `arcana=MIDDLE` which Minchiate (and any non-Earthman tarot family) doesn't classify its courts as — Minchiate courts are MINOR per its standard structure. User spec confirmed: my_sign picker = courts + Major 0/1 for EVERY deck (NOT segment-limited, NOT arcana-classification-limited). Fix: broaden the filter to `arcana__in=[MIDDLE, MINOR]` so courts qualify regardless of how the deck classifies them. For Earthman, behavior unchanged (no MINOR 11-14 cards exist in seed — its courts are exclusively MIDDLE); for Minchiate + RWS, picker expands from 2 → 18 cards as designed. Two side-by-side suit queries (brands_crowns + blades_grails) collapse to a single 4-suit query since the union was already covering all 4 — that was historical artifact, not segment-limiting in effect. Bug 2: deleting the user's sig on /billboard/my-sign/ blanks the My Sea applet on /gameboard/ even though the saved MySeaDraw spread is still in the DB (visible on /billboard/my-sea/), reappearing only when any sig is re-selected. Root cause: `_applet-my-sea.html` gated the slot-render branch on `{% if not request.user.significator_id %}` first, treating no-sig as "no draws yet" regardless of actual draw state. But MySeaDraw rows carry their own `significator_id` snapshot at first-draw time (`gameboard.models.MySeaDraw` doc lines 130-132) precisely so user-sig clearing doesn't invalidate saved draws — the template ignored that contract. Fix: invert the template branches — slot render now keys solely on `my_sea_slots`; the sig-gate Brief banner only fires in the empty-state branch when ALSO `not request.user.significator_id` (the "fresh user, no draws, no sig" case). MySeaDraw display now correctly decoupled from current sig state — sig deletion only matters for users who haven't drawn yet. Companion code: `_sig_unique_cards_for_deck` docstring updated to articulate the cross-deck symmetry rule ("courts recognized by rank 11-14 regardless of arcana classification") + the spec-confirmed non-segment-limitation. 1 new regression IT in `GameboardViewTest.test_my_sea_applet_renders_slots_even_when_user_significator_cleared` locks Bug 2's fix: creates a MySeaDraw row w. one filled slot, then sets User.significator=None, GETs /gameboard/, asserts the filled slot still renders + "No draws yet" empty state is absent. Tests: 1 new IT green; 810/810 epic+gameboard+billboard ITs green; 1290/1290 IT+UT total green (70s, +1 from A.3's 1289). No FT changes needed — Bug 1's fix changes the count of cards in the picker grid; existing FTs that count cards target Earthman where the count is unchanged. Visual verify still pending; user will confirm both fixes via Claudezilla browser session Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:16:55 -04:00
after room deck_variant lookup) and the solo My Sign picker (called
via personal_sig_cards from User.equipped_deck).
"Court cards" are recognized by rank (11=Page, 12=Knight, 13=Queen,
14=King) regardless of arcana classification: Earthman classifies its
courts as MIDDLE arcana, but other tarot families (Minchiate Fiorentine,
RWS) classify them as MINOR. Including both classifications gives every
deck the symmetric 18-card pile (16 courts × 4 suits + 2 majors at
numbers 0/1) instead of letting non-Earthman decks fall to 2 cards just
because they don't use the MIDDLE classification. Cross-deck eligibility
is NOT segment-limited all 4 suits' courts qualify per user spec
2026-05-25.
"""
if deck_variant is None:
return []
A.3-polish: cross-deck sig picker (MINOR + MIDDLE courts) + My Sea applet sig-decoupling — TDD. Two user-reported bugs caught during A.3 visual verify (2026-05-25 PM). Bug 1: my_sign picker shows only 2 cards (Major 0 + 1) for Minchiate-equipped users since `_sig_unique_cards_for_deck` filters by `arcana=MIDDLE` which Minchiate (and any non-Earthman tarot family) doesn't classify its courts as — Minchiate courts are MINOR per its standard structure. User spec confirmed: my_sign picker = courts + Major 0/1 for EVERY deck (NOT segment-limited, NOT arcana-classification-limited). Fix: broaden the filter to `arcana__in=[MIDDLE, MINOR]` so courts qualify regardless of how the deck classifies them. For Earthman, behavior unchanged (no MINOR 11-14 cards exist in seed — its courts are exclusively MIDDLE); for Minchiate + RWS, picker expands from 2 → 18 cards as designed. Two side-by-side suit queries (brands_crowns + blades_grails) collapse to a single 4-suit query since the union was already covering all 4 — that was historical artifact, not segment-limiting in effect. Bug 2: deleting the user's sig on /billboard/my-sign/ blanks the My Sea applet on /gameboard/ even though the saved MySeaDraw spread is still in the DB (visible on /billboard/my-sea/), reappearing only when any sig is re-selected. Root cause: `_applet-my-sea.html` gated the slot-render branch on `{% if not request.user.significator_id %}` first, treating no-sig as "no draws yet" regardless of actual draw state. But MySeaDraw rows carry their own `significator_id` snapshot at first-draw time (`gameboard.models.MySeaDraw` doc lines 130-132) precisely so user-sig clearing doesn't invalidate saved draws — the template ignored that contract. Fix: invert the template branches — slot render now keys solely on `my_sea_slots`; the sig-gate Brief banner only fires in the empty-state branch when ALSO `not request.user.significator_id` (the "fresh user, no draws, no sig" case). MySeaDraw display now correctly decoupled from current sig state — sig deletion only matters for users who haven't drawn yet. Companion code: `_sig_unique_cards_for_deck` docstring updated to articulate the cross-deck symmetry rule ("courts recognized by rank 11-14 regardless of arcana classification") + the spec-confirmed non-segment-limitation. 1 new regression IT in `GameboardViewTest.test_my_sea_applet_renders_slots_even_when_user_significator_cleared` locks Bug 2's fix: creates a MySeaDraw row w. one filled slot, then sets User.significator=None, GETs /gameboard/, asserts the filled slot still renders + "No draws yet" empty state is absent. Tests: 1 new IT green; 810/810 epic+gameboard+billboard ITs green; 1290/1290 IT+UT total green (70s, +1 from A.3's 1289). No FT changes needed — Bug 1's fix changes the count of cards in the picker grid; existing FTs that count cards target Earthman where the count is unchanged. Visual verify still pending; user will confirm both fixes via Claudezilla browser session Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:16:55 -04:00
courts = list(TarotCard.objects.filter(
deck_variant=deck_variant,
A.3-polish: cross-deck sig picker (MINOR + MIDDLE courts) + My Sea applet sig-decoupling — TDD. Two user-reported bugs caught during A.3 visual verify (2026-05-25 PM). Bug 1: my_sign picker shows only 2 cards (Major 0 + 1) for Minchiate-equipped users since `_sig_unique_cards_for_deck` filters by `arcana=MIDDLE` which Minchiate (and any non-Earthman tarot family) doesn't classify its courts as — Minchiate courts are MINOR per its standard structure. User spec confirmed: my_sign picker = courts + Major 0/1 for EVERY deck (NOT segment-limited, NOT arcana-classification-limited). Fix: broaden the filter to `arcana__in=[MIDDLE, MINOR]` so courts qualify regardless of how the deck classifies them. For Earthman, behavior unchanged (no MINOR 11-14 cards exist in seed — its courts are exclusively MIDDLE); for Minchiate + RWS, picker expands from 2 → 18 cards as designed. Two side-by-side suit queries (brands_crowns + blades_grails) collapse to a single 4-suit query since the union was already covering all 4 — that was historical artifact, not segment-limiting in effect. Bug 2: deleting the user's sig on /billboard/my-sign/ blanks the My Sea applet on /gameboard/ even though the saved MySeaDraw spread is still in the DB (visible on /billboard/my-sea/), reappearing only when any sig is re-selected. Root cause: `_applet-my-sea.html` gated the slot-render branch on `{% if not request.user.significator_id %}` first, treating no-sig as "no draws yet" regardless of actual draw state. But MySeaDraw rows carry their own `significator_id` snapshot at first-draw time (`gameboard.models.MySeaDraw` doc lines 130-132) precisely so user-sig clearing doesn't invalidate saved draws — the template ignored that contract. Fix: invert the template branches — slot render now keys solely on `my_sea_slots`; the sig-gate Brief banner only fires in the empty-state branch when ALSO `not request.user.significator_id` (the "fresh user, no draws, no sig" case). MySeaDraw display now correctly decoupled from current sig state — sig deletion only matters for users who haven't drawn yet. Companion code: `_sig_unique_cards_for_deck` docstring updated to articulate the cross-deck symmetry rule ("courts recognized by rank 11-14 regardless of arcana classification") + the spec-confirmed non-segment-limitation. 1 new regression IT in `GameboardViewTest.test_my_sea_applet_renders_slots_even_when_user_significator_cleared` locks Bug 2's fix: creates a MySeaDraw row w. one filled slot, then sets User.significator=None, GETs /gameboard/, asserts the filled slot still renders + "No draws yet" empty state is absent. Tests: 1 new IT green; 810/810 epic+gameboard+billboard ITs green; 1290/1290 IT+UT total green (70s, +1 from A.3's 1289). No FT changes needed — Bug 1's fix changes the count of cards in the picker grid; existing FTs that count cards target Earthman where the count is unchanged. Visual verify still pending; user will confirm both fixes via Claudezilla browser session Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:16:55 -04:00
arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS],
number__in=[11, 12, 13, 14],
))
major = list(TarotCard.objects.filter(
deck_variant=deck_variant,
arcana=TarotCard.MAJOR,
number__in=[0, 1],
))
A.3-polish: cross-deck sig picker (MINOR + MIDDLE courts) + My Sea applet sig-decoupling — TDD. Two user-reported bugs caught during A.3 visual verify (2026-05-25 PM). Bug 1: my_sign picker shows only 2 cards (Major 0 + 1) for Minchiate-equipped users since `_sig_unique_cards_for_deck` filters by `arcana=MIDDLE` which Minchiate (and any non-Earthman tarot family) doesn't classify its courts as — Minchiate courts are MINOR per its standard structure. User spec confirmed: my_sign picker = courts + Major 0/1 for EVERY deck (NOT segment-limited, NOT arcana-classification-limited). Fix: broaden the filter to `arcana__in=[MIDDLE, MINOR]` so courts qualify regardless of how the deck classifies them. For Earthman, behavior unchanged (no MINOR 11-14 cards exist in seed — its courts are exclusively MIDDLE); for Minchiate + RWS, picker expands from 2 → 18 cards as designed. Two side-by-side suit queries (brands_crowns + blades_grails) collapse to a single 4-suit query since the union was already covering all 4 — that was historical artifact, not segment-limiting in effect. Bug 2: deleting the user's sig on /billboard/my-sign/ blanks the My Sea applet on /gameboard/ even though the saved MySeaDraw spread is still in the DB (visible on /billboard/my-sea/), reappearing only when any sig is re-selected. Root cause: `_applet-my-sea.html` gated the slot-render branch on `{% if not request.user.significator_id %}` first, treating no-sig as "no draws yet" regardless of actual draw state. But MySeaDraw rows carry their own `significator_id` snapshot at first-draw time (`gameboard.models.MySeaDraw` doc lines 130-132) precisely so user-sig clearing doesn't invalidate saved draws — the template ignored that contract. Fix: invert the template branches — slot render now keys solely on `my_sea_slots`; the sig-gate Brief banner only fires in the empty-state branch when ALSO `not request.user.significator_id` (the "fresh user, no draws, no sig" case). MySeaDraw display now correctly decoupled from current sig state — sig deletion only matters for users who haven't drawn yet. Companion code: `_sig_unique_cards_for_deck` docstring updated to articulate the cross-deck symmetry rule ("courts recognized by rank 11-14 regardless of arcana classification") + the spec-confirmed non-segment-limitation. 1 new regression IT in `GameboardViewTest.test_my_sea_applet_renders_slots_even_when_user_significator_cleared` locks Bug 2's fix: creates a MySeaDraw row w. one filled slot, then sets User.significator=None, GETs /gameboard/, asserts the filled slot still renders + "No draws yet" empty state is absent. Tests: 1 new IT green; 810/810 epic+gameboard+billboard ITs green; 1290/1290 IT+UT total green (70s, +1 from A.3's 1289). No FT changes needed — Bug 1's fix changes the count of cards in the picker grid; existing FTs that count cards target Earthman where the count is unchanged. Visual verify still pending; user will confirm both fixes via Claudezilla browser session Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 00:16:55 -04:00
return courts + major
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
def _sig_unique_cards(room):
"""Return the 18 unique TarotCard objects that form one sig pile."""
return _sig_unique_cards_for_deck(_room_deck_variant(room))
def personal_sig_cards(user):
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
My Sign: Brief banner + Earthman [Shabby Cardstock] backup deck when no equipped — TDD User-reported gap on /billboard/my-sign/ — admin user's only deck was in-use as `TableSeat.deck_variant` in another room (Wonderbeard) → `equipped_deck` cleared → previous my-sign template showed "Equip a card deck first…" w. no actionable next step. User scoped fix: don't force equip, just nudge via a Brief banner "Look!—no deck is equipped. Navigate to the Game Kit to equip one (FYI) or (NVM) proceed with the Earthman [Shabby Cardstock] deck.", title "Default deck warning". NVM dismisses + picker proceeds against an Earthman card pile labeled in-copy as the temporary backup; FYI links to /gameboard/ (Game Kit equip). User-modified line text in template: "Paperboard" → "Cardstock" mid-session ; **helper fallback** (epic/models.py): `personal_sig_cards(user)` now falls back to `DeckVariant.objects.filter(slug='earthman').first()` when `user.equipped_deck` is None — same 16-or-18 card pile, just sourced from the canonical Earthman deck rather than the empty FK. No new DeckVariant row needed; "Shabby Cardstock" is purely UX framing (cards are the same TarotCard records the room sig-select uses). Preserves the existing helper signature so no callers had to change ; **view + template** (billboard/views.py + my_sign.html): view passes `no_equipped_deck` + `show_backup_intro_banner` flags. Template removes the old `{% if not equipped_deck %}` forced-equip branch — picker now renders unconditionally w. cards from the backup helper when no deck is equipped. Brief banner fires via `Brief.showBanner({...})` on DOMContentLoaded when `show_backup_intro_banner` is true — gets h2-overlay positioning + NVM behavior + portal styling for free (per [[sprint-baltimorean-note-unlock-may18]] portrait h2 measurement in note.js's `_alignToH2`). Added `<script src="note.js">` to my_sign.html since the page didn't load it before. Post-render JS tags the Brief w. a `.my-sign-intro-banner` class so FTs (and any future my-sign-specific styling) can distinguish this nudge from other Briefs on the page ; **TDD trail** — 4 new FTs in `MySignBackupDeckTest` (test_bill_my_sign.py): T1 banner renders w. "Default deck warning" title + "no deck is equipped" + "Shabby Cardstock" copy + both action btns visible; T2 picker still populates 16 cards from backup; T3 NVM click removes the banner from the DOM; T4 FYI href ends w. /gameboard/. Initial reds (`NoSuchElementException` on all 4) confirmed before implementation. Plus 1 new IT in `PersonalSigCardsTest` pinning the helper fallback (16 cards w. all `c.deck_variant.slug == "earthman"`) ; pre-existing change picked up: `static_src/scss/rootvars.scss` (user-modified mid-session) ; 1020 IT/UT green; 7 FTs green (3 picker happy-path + 4 backup deck) in 56s. Sprint 4a-follow complete — primary deferral from Sprint 4a (deck-source fallback UX) now landed. Unblocks Sprint 4b (My Sea gating w. --terUser link to /billboard/my-sign/ when no sig set) 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:49:49 -04:00
User.equipped_deck instead of room.deck_variant. For the Game Sign
picker at /billboard/my-sign/. Same 18-card pile (16 middle arcana +
Major 0 + 1), filtered by the user's Note unlocks (Schizo/Nomad lines).
Fallback: if the user has no equipped_deck (e.g. their only deck is
in-use as a TableSeat.deck_variant in an active room), fall back to
the Earthman deck. The picker UI labels this "Earthman [Shabby
Paperboard]" via a Brief banner — the cards are identical, the deck
identity is just a UX framing for "temporary, doesn't belong to your
Game Kit inventory"."""
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
return _filter_major_unlocks(_sig_unique_cards_for_deck(deck), user)
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
def _filter_major_unlocks(cards, user):
"""Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
if user is None or not user.is_authenticated:
return [c for c in cards if c.arcana != TarotCard.MAJOR]
earned = set(user.notes.values_list("slug", flat=True))
return [
c for c in cards
if c.arcana != TarotCard.MAJOR
or (c.number == 0 and earned & {"nomad", "super-nomad"})
or (c.number == 1 and earned & {"schizo", "super-schizo"})
]
def levity_sig_cards(room, user=None):
"""Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks."""
return _filter_major_unlocks(_sig_unique_cards(room), user)
def gravity_sig_cards(room, user=None):
"""Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks."""
return _filter_major_unlocks(_sig_unique_cards(room), user)
def sig_seat_order(room):
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
seats = list(room.table_seats.all())
return sorted(seats, key=lambda s: _order.get(s.role, 99))
def active_sig_seat(room):
"""Return the first seat without a significator in canonical order, or None."""
for seat in sig_seat_order(room):
if seat.significator_id is None:
return seat
return None
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
# ── Astrological reference tables (seeded, never user-edited) ─────────────────
class Sign(models.Model):
FIRE = 'Fire'
EARTH = 'Earth'
AIR = 'Air'
WATER = 'Water'
ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)]
CARDINAL = 'Cardinal'
FIXED = 'Fixed'
MUTABLE = 'Mutable'
MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)]
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ♈ ♉ … ♓
element = models.CharField(max_length=5, choices=ELEMENT_CHOICES)
modality = models.CharField(max_length=8, choices=MODALITY_CHOICES)
order = models.PositiveSmallIntegerField(unique=True) # 011, Aries first
start_degree = models.FloatField() # 0, 30, 60 … 330
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class Planet(models.Model):
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
order = models.PositiveSmallIntegerField(unique=True) # 09, Sun first
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class AspectType(models.Model):
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ☌ ⚹ □ △ ☍
angle = models.PositiveSmallIntegerField() # 0, 60, 90, 120, 180
orb = models.FloatField() # max allowed orb in degrees
class Meta:
ordering = ['angle']
def __str__(self):
return self.name
class HouseLabel(models.Model):
"""Life-area label for each of the 12 astrological houses (distinctions)."""
number = models.PositiveSmallIntegerField(unique=True) # 112
name = models.CharField(max_length=30)
keywords = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['number']
def __str__(self):
return f"{self.number}: {self.name}"
# ── Character ─────────────────────────────────────────────────────────────────
class Character(models.Model):
"""A gamer's player-character for one seat in one game session.
Lifecycle:
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
- Created (draft) when gamer opens CAST SKY overlay.
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
- confirmed_at set on confirm locked.
- retired_at set on retirement archived (seat may hold a new Character).
Active character for a seat: confirmed_at__isnull=False, retired_at__isnull=True.
"""
PORPHYRY = 'O'
PLACIDUS = 'P'
KOCH = 'K'
WHOLE = 'W'
HOUSE_SYSTEM_CHOICES = [
(PORPHYRY, 'Porphyry'),
(PLACIDUS, 'Placidus'),
(KOCH, 'Koch'),
(WHOLE, 'Whole Sign'),
]
# ── seat relationship ─────────────────────────────────────────────────
seat = models.ForeignKey(
TableSeat, on_delete=models.CASCADE, related_name='characters',
)
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
# ── significator (set at CAST SKY) ────────────────────────────────────
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
significator = models.ForeignKey(
TarotCard, null=True, blank=True,
on_delete=models.SET_NULL, related_name='character_significators',
)
# ── sky input (what the gamer entered) ─────────────────────────────
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
birth_dt = models.DateTimeField(null=True, blank=True) # UTC
birth_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
birth_lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
birth_place = models.CharField(max_length=200, blank=True) # display string only
house_system = models.CharField(
max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY,
)
# ── computed sky snapshot (full PySwiss response) ───────────────────
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
chart_data = models.JSONField(null=True, blank=True)
btn-primary label renames + stage-card polarity color refinements — two interleaved threads from one session, committing together since both touch sig + sea stage cards ; LABEL RENAMES: PICK SIGS → SCAN SIGS (room.html #id_pick_sigs_btn), PICK SKY → CAST SKY (room.html #id_pick_sky_btn × 2), PICK SEA → DRAW SEA (room.html #id_pick_sea_btn), TAKE SIG → SAVE SIG (sig-select.js _takeSigBtn.textContent × 2 callsites + section comment) — Element IDs (id_pick_sky_btn etc.), URL names (epic:pick_sigs, epic:pick_sky), and Python state enums (TableStatus.PICK_SKY, PICK_SEA, SIG_SELECT) intentionally retained as stable identifiers; the renamed text is purely the .btn-primary user-facing label ; FT + IT mentions of the old labels swept in test_game_room_select_{sig,sky,sea,role}.py, test_billboard.py, setup_sea_session.py mgmt cmd, apps/epic/{views,utils,models,tasks,tests/integrated/test_views}.py, SigSelectSpec.js, sky_overlay/sea_overlay/dashboard/sky.html, _card-deck.scss, _sky.scss — all docstring/comment references updated for cascade-grep cleanliness ; STAGE-CARD COLOR + CLASS REFINEMENTS (earlier in session): sig-stage card text colour split per polarity — gravity gets --terUser on .fan-card-name + .fan-card-reversal-{name,qualifier} + .sig-qualifier-{above,below}, levity gets --quiUser on the same five slots; all selectors prefixed w. .sig-stage-card to match the 0,4,0 specificity of the default `.sig-stage .sig-stage-card .fan-card-face .sig-qualifier-*` rule (without the prefix the polarity overrides lose the cascade — .sig-qualifier-below was visibly stuck on the default --quiUser) ; .stat-face-label gets polarity-inverse colours — gravity stat-block bg is --secUser (opposite of card's --priUser) so the label takes --quiUser to stay legible; levity is the symmetric flip (label = --terUser on --priUser stat-block bg) ; levity card title/qualifier drop-shadow swapped from rgba(0,0,0,…) → rgba(255,255,255,…) — dark drop reads as harsh smudge against the inverted-frame levity --secUser bg; applied to both sig-overlay[data-polarity="levity"] stage card AND sea-stage--levity via $_sea-title-shadow-levity (former shared $_sea-title-shadow split into per-polarity {levity,gravity} variants) ; reversal-face class/content alignment so each `.fan-card-reversal-*` class always carries its semantic content — DOM order per arcana type controls visual layout after the 180° SPIN (DOM-second appears visually on top): Major → title in .fan-card-reversal-name @ DOM-second (visually top after spin), qualifier in .fan-card-reversal-qualifier @ DOM-first; Non-major → title in .fan-card-reversal-name @ DOM-first (visually bottom after spin), qualifier in .fan-card-reversal-qualifier @ DOM-second (preserves the original "qualifier word reads first after spin" layout for Middle/Minor arcana — e.g. "Relieving / Eight of Crowns" not "Eight of Crowns / Relieving") ; _tarot_fan.html renders per-arcana DOM order directly (Django template branches handle both layouts); sig + sea overlays render a fixed two-`<p>` skeleton (one DOM order) so stage-card.js's populator dynamically rewrites the two `<p>`s' className per arcana — Major/override branch flips DOM-second to .fan-card-reversal-name + content, DOM-first to .fan-card-reversal-qualifier; non-major branch keeps DOM-first as .fan-card-reversal-name + title, DOM-second as .fan-card-reversal-qualifier + reversalQualifier-or-polarity-fallback ; SigSelectSpec.js + SeaDealSpec.js fixtures + Major reversed-face assertion updated for the new semantic — TDD Code architected by Disco DeDisco <discodedisco@outlook.com> Git commit message Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:25:10 -04:00
# ── celtic cross spread (added at DRAW SEA) ───────────────────────────
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz PySwiss: - calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs) - /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone) - aspects included in /api/chart/ response - timezonefinder==8.2.2 added to requirements - 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields) Main app: - Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033) - Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross, confirmed_at/retired_at lifecycle (migration 0034) - natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution, computes planet-in-house distinctions, returns enriched JSON - natus_save view: find-or-create draft Character, confirmed_at on action='confirm' - natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects, ASC/MC axes); NatusWheel.draw() / redraw() / clear() - _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill, NVM / SAVE SKY footer; html.natus-open class toggle pattern - _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown, portrait collapse at 600px, landscape sidebar z-index sink - room.html: include overlay when table_status == SKY_SELECT Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
celtic_cross = models.JSONField(null=True, blank=True)
# ── lifecycle ─────────────────────────────────────────────────────────
created_at = models.DateTimeField(auto_now_add=True)
confirmed_at = models.DateTimeField(null=True, blank=True)
retired_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
status = 'confirmed' if self.confirmed_at else 'draft'
return f"Character(seat={self.seat_id}, {status})"
@property
def is_confirmed(self):
return self.confirmed_at is not None
@property
def is_active(self):
return self.confirmed_at is not None and self.retired_at is None