2026-03-24 21:07:01 -04:00
import random
2026-03-13 00:31:17 -04:00
import uuid
2026-03-13 17:31:52 -04:00
from datetime import timedelta
2026-03-12 15:05:02 -04:00
from django . db import models
2026-04-05 22:01:23 -04:00
from django . db . models import UniqueConstraint
2026-03-13 00:31:17 -04:00
from django . db . models . signals import post_save
from django . dispatch import receiver
from django . conf import settings
2026-03-13 17:31:52 -04:00
from django . utils import timezone
from apps . lyric . models import Token
2026-03-13 00:31:17 -04:00
class Room ( models . Model ) :
GATHERING = " GATHERING "
OPEN = " OPEN "
RENEWAL_DUE = " RENEWAL_DUE "
GATE_STATUS_CHOICES = [
2026-03-14 01:14:05 -04:00
( GATHERING , " GATHERING GAMERS " ) ,
2026-03-13 00:31:17 -04:00
( OPEN , " Open " ) ,
( RENEWAL_DUE , " Renewal Due " ) ,
]
PRIVATE = " PRIVATE "
PUBLIC = " PUBLIC "
INVITE_ONLY = " INVITE ONLY "
VISIBILITY_CHOICES = [
( PRIVATE , " Private " ) ,
( PUBLIC , " Public " ) ,
( INVITE_ONLY , " Invite Only " ) ,
]
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
ROLE_SELECT = " ROLE_SELECT "
SIG_SELECT = " SIG_SELECT "
2026-04-09 01:17:24 -04:00
SKY_SELECT = " SKY_SELECT "
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
IN_GAME = " IN_GAME "
TABLE_STATUS_CHOICES = [
( ROLE_SELECT , " Role Select " ) ,
( SIG_SELECT , " Significator Select " ) ,
2026-04-09 01:17:24 -04:00
( SKY_SELECT , " Sky Select " ) ,
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
( IN_GAME , " In Game " ) ,
]
2026-03-13 00:31:17 -04:00
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 )
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
table_status = models . CharField (
max_length = 20 , choices = TABLE_STATUS_CHOICES , null = True , blank = True
)
2026-04-09 01:17:24 -04:00
sig_select_started_at = models . DateTimeField ( null = True , blank = True )
2026-03-13 17:31:52 -04:00
renewal_period = models . DurationField ( null = True , blank = True , default = timedelta ( days = 7 ) )
2026-03-13 00:31:17 -04:00
created_at = models . DateTimeField ( auto_now_add = True )
board_state = models . JSONField ( default = dict )
seed_count = models . IntegerField ( default = 12 )
2026-03-13 18:37:19 -04:00
2026-03-13 00:31:17 -04:00
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 )
2026-03-15 16:08:34 -04:00
debited_token_type = models . CharField ( max_length = 8 , null = True , blank = True )
debited_token_expires_at = models . DateTimeField ( null = True , blank = True )
2026-03-13 00:31:17 -04:00
2026-03-12 15:05:02 -04:00
2026-03-13 18:37:19 -04:00
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 )
2026-03-13 00:31:17 -04:00
@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 )
2026-03-13 17:31:52 -04:00
2026-03-14 22:00:16 -04:00
def select_token ( user ) :
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.
2026-03-14 22:00:16 -04:00
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 ( )
2026-03-13 17:31:52 -04:00
def debit_token ( user , slot , token ) :
2026-03-15 16:08:34 -04:00
slot . debited_token_type = token . token_type
2026-03-13 17:31:52 -04:00
if token . token_type == Token . COIN :
token . current_room = slot . room
2026-03-13 22:51:42 -04:00
period = slot . room . renewal_period or timedelta ( days = 7 )
token . next_ready_at = timezone . now ( ) + period
2026-03-13 17:31:52 -04:00
token . save ( )
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 " ] )
2026-03-16 00:07:52 -04:00
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 ) :
2026-03-15 16:08:34 -04:00
slot . debited_token_expires_at = token . expires_at
2026-03-13 17:31:52 -04:00
token . delete ( )
slot . gamer = user
slot . status = GateSlot . FILLED
slot . filled_at = timezone . now ( )
slot . save ( )
2026-03-13 22:51:42 -04:00
room = slot . room
if not room . gate_slots . filter ( status = GateSlot . EMPTY ) . exists ( ) :
room . gate_status = Room . OPEN
room . save ( )
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
2026-03-25 01:50:06 -04:00
SIG_SEAT_ORDER = [ " PC " , " NC " , " EC " , " SC " , " AC " , " BC " ]
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
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 )
2026-03-25 01:50:06 -04:00
significator = models . ForeignKey (
" TarotCard " , null = True , blank = True ,
on_delete = models . SET_NULL , related_name = " significator_seats " ,
)
2026-04-27 22:38:07 -04:00
deck_variant = models . ForeignKey (
" DeckVariant " , null = True , blank = True ,
on_delete = models . SET_NULL , related_name = " active_seats " ,
)
2026-03-24 21:07:01 -04:00
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 " ) ,
]
2026-03-24 21:07:01 -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
# 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)
}
2026-03-24 21:07:01 -04:00
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 )
2026-03-24 21:07:01 -04:00
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 (
f " apps/epic/images/cards-faces/ { self . slug } / { self . slug } -back.png "
)
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 ]
2026-03-24 21:52:57 -04:00
@property
def short_key ( self ) :
""" First dash-separated word of slug — used as an HTML id component. """
return self . slug . split ( ' - ' ) [ 0 ]
2026-03-24 21:07:01 -04:00
def __str__ ( self ) :
return f " { self . name } ( { self . card_count } cards) "
class TarotCard ( models . Model ) :
MAJOR = " MAJOR "
2026-05-01 02:06:55 -04:00
MINOR = " MINOR " # pip cards (numbers 1-10)
MIDDLE = " MIDDLE " # Earthman court cards (M/J/Q/K, numbers 11-14)
2026-03-24 21:07:01 -04:00
ARCANA_CHOICES = [
2026-04-05 22:32:40 -04:00
( MAJOR , " Major Arcana " ) ,
( MINOR , " Minor Arcana " ) ,
( MIDDLE , " Middle Arcana " ) ,
2026-03-24 21:07:01 -04:00
]
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 "
2026-03-24 21:07:01 -04:00
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 " ) ,
2026-03-24 21:07:01 -04:00
]
deck_variant = models . ForeignKey (
DeckVariant , null = True , blank = True ,
on_delete = models . CASCADE , related_name = " cards " ,
)
name = models . CharField ( max_length = 200 )
2026-04-05 22:32:40 -04:00
arcana = models . CharField ( max_length = 6 , choices = ARCANA_CHOICES )
2026-03-24 21:07:01 -04:00
suit = models . CharField ( max_length = 10 , choices = SUIT_CHOICES , null = True , blank = True )
2026-04-05 22:32:40 -04:00
icon = models . CharField ( max_length = 50 , blank = True , default = ' ' ) # FA icon override (e.g. major arcana)
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
number = models . IntegerField ( ) # 0– 21 major (Fiorentine); 0– 49 major (Earthman); 1– 14 minor
2026-03-24 21:07:01 -04:00
slug = models . SlugField ( max_length = 120 )
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
correspondence = models . CharField ( max_length = 200 , blank = True ) # Tarot / Minchiate equivalent
2026-03-24 21:07:01 -04:00
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.
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
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 = ' ' )
2026-04-30 23:36:35 -04:00
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)
2026-04-28 20:22:19 -04:00
energies = models . JSONField ( default = list ) # list of {type, effect} dicts — Energy interactions
operations = models . JSONField ( default = list ) # list of {type, effect} dicts — Operation interactions
2026-03-24 21:07:01 -04:00
keywords_upright = models . JSONField ( default = list )
keywords_reversed = models . JSONField ( default = list )
2026-04-07 00:22:04 -04:00
cautions = models . JSONField ( default = list )
2026-03-24 21:07:01 -04:00
class Meta :
ordering = [ " deck_variant " , " arcana " , " suit " , " number " ]
unique_together = [ ( " deck_variant " , " slug " ) ]
2026-03-25 00:24:26 -04:00
@staticmethod
def _to_roman ( n ) :
if n == 0 :
return ' 0 '
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 ' }
2026-04-29 00:20:55 -04:00
if self . number in court :
return court [ self . number ]
return ' A ' if self . number == 1 else str ( self . number )
2026-03-25 00:24:26 -04:00
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
def emanation_for ( self , polarity ) :
""" Return the upright title for a given polarity ( ' levity ' or ' gravity ' ).
2026-04-30 21:51:23 -04:00
Falls back to name_title ( group prefix stripped ) for cards without a
polarity split . """
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
if polarity == ' levity ' and self . levity_emanation :
return self . levity_emanation
if polarity == ' gravity ' and self . gravity_emanation :
return self . gravity_emanation
2026-04-30 21:51:23 -04:00
return self . name_title
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
def reversal_for ( self , polarity ) :
""" Return the reversed title for a given polarity.
2026-04-30 21:51:23 -04:00
Falls back to reversal_qualifier ( blank = same as emanation_for ) . """
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
if polarity == ' levity ' and self . levity_reversal :
return self . levity_reversal
if polarity == ' gravity ' and self . gravity_reversal :
return self . gravity_reversal
2026-04-30 21:51:23 -04:00
return self . reversal_qualifier or self . emanation_for ( polarity )
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
fix: significator_reversed=polarity bug + Pattern B name-swap rendering + qualifier-aware applet faces + sticky PAID DRAW + cooldown anchor on User + stat-block polarity unification across Sig/Sea/Fan/applets
Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]).
**Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]].
**Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard).
**Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]].
**Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior).
**Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]].
**Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint):
- My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`).
- Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below.
- `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly.
**Memory recorded**:
1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT.
2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity.
3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table.
4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine.
**Files** (every uncommitted file folded in — session work + pre-existing modifications):
Models / migrations:
- `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField.
- `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema.
- `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18.
- `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`.
- `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed.
- `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema.
- `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator.
- `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema.
Views:
- `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`.
JS:
- `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face.
- `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card.
Templates:
- `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`.
- `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label.
- `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware).
- `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards.
- `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags.
SCSS:
- `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`.
- `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs.
- `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots.
Tests:
- `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`.
- `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback).
- `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end).
- `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:06:35 -04:00
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 }
2026-03-25 00:46:48 -04:00
@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
2026-05-01 02:06:55 -04:00
@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 ' '
2026-03-25 00:24:26 -04:00
@property
def suit_icon ( self ) :
2026-04-05 22:32:40 -04:00
if self . icon :
return self . icon
2026-03-25 00:24:26 -04:00
if self . arcana == self . MAJOR :
return ' '
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 ' ,
2026-03-25 00:24:26 -04:00
} . 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
WhiteNoise compressed manifest is active ) flow through . """
if not self . deck_variant . has_card_images :
return " "
from django . templatetags . static import static
return static (
f " apps/epic/images/cards-faces/ { self . deck_variant . slug } / { self . image_filename } "
)
2026-04-07 00:22:04 -04:00
@property
def cautions_json ( self ) :
import json
return json . dumps ( self . cautions )
2026-04-28 17:18:16 -04:00
@property
2026-04-28 20:22:19 -04:00
def energies_json ( self ) :
2026-04-28 17:18:16 -04:00
import json
2026-04-28 20:22:19 -04:00
return json . dumps ( self . energies )
2026-04-28 17:18:16 -04:00
@property
2026-04-28 20:22:19 -04:00
def operations_json ( self ) :
2026-04-28 17:18:16 -04:00
import json
2026-04-28 20:22:19 -04:00
return json . dumps ( self . operations )
2026-04-28 17:18:16 -04:00
2026-03-24 21:07:01 -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 " ] )
2026-03-25 01:50:06 -04:00
2026-04-05 22:01:23 -04:00
# ── 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 '
)
2026-04-08 22:53:44 -04:00
seat = models . ForeignKey (
' TableSeat ' , null = True , blank = True ,
on_delete = models . SET_NULL , related_name = ' sig_reservation ' ,
)
2026-04-05 22:01:23 -04:00
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 )
2026-04-09 01:17:24 -04:00
ready = models . BooleanField ( default = False )
countdown_remaining = models . IntegerField ( null = True , blank = True )
2026-04-05 22:01:23 -04:00
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 ' ,
) ,
]
2026-03-25 01:50:06 -04:00
# ── Significator deck helpers ─────────────────────────────────────────────────
2026-04-28 00:45:22 -04:00
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
2026-03-25 01:50:06 -04:00
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 ( 11 – 14 ) : 8 unique
SC / AC pair → BLADES + GRAILS Middle Arcana court cards ( 11 – 14 ) : 8 unique
NC / EC pair → MAJOR arcana numbers 0 and 1 : 2 unique
2026-03-25 01:50:06 -04:00
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 ) )
2026-03-25 01:50:06 -04:00
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.
"""
2026-04-05 22:01:23 -04:00
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 (
2026-04-05 22:01:23 -04:00
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 ] ,
2026-04-05 22:01:23 -04:00
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
2026-04-05 22:01:23 -04:00
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
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
2026-04-28 01:05:25 -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
2026-04-28 01:30:02 -04:00
or ( c . number == 0 and earned & { " nomad " , " super-nomad " } )
or ( c . number == 1 and earned & { " schizo " , " super-schizo " } )
2026-04-28 01:05:25 -04:00
]
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 )
2026-04-05 22:01:23 -04:00
2026-04-28 01:05:25 -04:00
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 )
2026-04-05 22:01:23 -04:00
2026-03-25 01:50:06 -04:00
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 ) # 0– 11, 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 ) # 0– 9, 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 ) # 1– 12
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 ' ,
)
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
# ── 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 ,
)
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
# ── 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