Files
python-tdd/src/apps/epic/models.py
Disco DeDisco 652cef09c0 image tree refactor: cards-faces/<family>/<variant>/ + RWS deck import (78 cards + back, renamed + pngquant'd)
Two related sub-changes, bundled because the new image_url path structure has to land in the same commit as the actual file relocations to keep `manage.py runserver` resolvable at every revision.

**(1) `DeckVariant.variant_dir_slug` + image-path tree restructure** — `apps/epic/models.py`. New `variant_dir_slug` property on DeckVariant returns the subdirectory name under `cards-faces/<family>/` for this deck's images. Mapping locked in 2026-05-26:
  - earthman family → "default"  (single-canonical today, locks the variant tier now so future Earthman editions slot in at `earthman/<variant>/` w.o. a path migration)
  - slug startswith "tarot-"     → strips that prefix (RWS slug `tarot-rider-waite-smith` → `rider-waite-smith`; "tarot-" is redundant under family=english)
  - otherwise                    → uses slug as-is (italian/minchiate-fiorentine-1860-1890)

Both `DeckVariant.back_image_url` + `TarotCard.image_url` updated from `cards-faces/<slug>/<filename>` to `cards-faces/<family>/<variant_dir_slug>/<filename>`. Flat → 2-tier tree groups by tarot tradition (italian/english/playing/earthman) rather than scattering 20+ deck dirs at the top level — payoff is most visible when adding multi-variant decks within a family (e.g., future RWS Centennial Edition, Pamela-A pristine scans, both land alongside the original at `english/<variant>/`).

Why this naming over alternatives the user considered:
  - `western-tarot/` — too broad (Italian Minchiate is also western tarot, defeats the partition)
  - `hermetic-dawn/` — too narrow (RWS lineage but doesn't generalize to pre-GD Marseille or non-RWS English decks)
  - `english/` — matches the existing `DeckVariant.FAMILY_CHOICES` field verbatim (source of truth, no new enum)

No tests assert on `image_url` paths (only on `image_filename` — the bare PNG names, which are unchanged). No JS references `cards-faces/` directly — sea.js + stage-card.js + utils.py all consume `image_url` server-rendered.

**(2) Minchiate Fiorentine 1860-1890 dir move** — 98 PNGs relocated from `cards-faces/minchiate-fiorentine-1860-1890/` to `cards-faces/italian/minchiate-fiorentine-1860-1890/`. Initially used `git mv source/ italian/` which Windows-flattened the move (files landed directly in italian/ instead of the nested variant subdir) — recovered by creating the variant subdir explicitly + `git mv *.png variant/`. Worth remembering for future deck imports: on Windows, `git mv dir/ existing_parent_dir/` does NOT auto-nest when the destination has existing entries.

**(3) RWS deck import** — 78 card images + 1 card-back PNG, dropped into `cards-faces/english/rider-waite-smith/`. Source: Wikipedia Commons (Public domain, attributable to Pamela Colman Smith). All scraped at 960px width per the size-vs-quality tradeoff conversation (matches the contour-stroke filter chain's largest CSS-display surface w. retina headroom; full-resolution 2100×3600 was 11.68MB/card → would balloon the page weight).

Filename normalization via one-shot `d:/tmp/rename_rws.py`:
  - Wikipedia patterns: `960px-Ace_of_Cups_(Rider-Waite_Smith_tarot_deck).png` → `tarot-rider-waite-smith-cups-01.png`
  - Trumps: `960px-The_Fool_(...)` → `tarot-rider-waite-smith-majors-00-the-fool.png` (English family uses "majors" not "trumps" per `_TRUMP_CATEGORY_BY_FAMILY` mapping)
  - Courts: `Page/Knight/Queen/King_of_<Suit>` → ranks 11/12/13/14 w. court-name suffix (e.g., `-cups-13-queen.png`)
  - Special: Aces of Pentacles + Aces of Swords Wikipedia-named as "One_of_..." instead of "Ace_of_..." (RANK_BY_WORD dict handles both)
  - Special: "Wheel_of_Fortune" major initially matched the MINOR_RE regex (Wheel + of + Fortune); fixed by adding both-rank-and-suit-in-known-vocab guard so non-real-suit "of" patterns fall through to MAJOR_RE
  - Card back: `Waite-Smith_Tarot_Roses_and_Lilies.png` → `tarot-rider-waite-smith-back.png`

Also: Queen of Cups was missing from the initial Wikipedia batch (caught by per-suit count audit: cups=13, others=14); user grabbed + dropped it in separately, scripted rename was rerun for that single file.

pngquant pass: `--quality=65-85 --speed=1 --strip --skip-if-larger --ext=.png --force` — 219MB → 76MB across the 78 cards (~65% reduction, ~975 KB/card average). Queen-of-Cups single-file pass: 2.4MB → 856KB.

Tests: 834/834 green across epic + gameboard + billboard (and 181/181 epic-isolated post-rename + collectstatic). collectstatic recopied all 176 PNGs (98 minchiate + 78 RWS) into the build dir; manifest hashes refresh.

Tomorrow: A.8 room.html sprint can now proceed w. RWS image-equipped (`has_card_images=True`) the same way Minchiate already does — image-mode SCSS already in place from A.5-A.7 polish. Future Shop applet entries: user mentioned a few decks slated as exclusively-purchasable via wallet shop (paid-only deck variants).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:51:12 -04:00

951 lines
39 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import random
import uuid
from datetime import timedelta
from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils import timezone
from apps.lyric.models import Token
class Room(models.Model):
GATHERING = "GATHERING"
OPEN = "OPEN"
RENEWAL_DUE = "RENEWAL_DUE"
GATE_STATUS_CHOICES = [
(GATHERING, "GATHERING GAMERS"),
(OPEN, "Open"),
(RENEWAL_DUE, "Renewal Due"),
]
PRIVATE = "PRIVATE"
PUBLIC = "PUBLIC"
INVITE_ONLY = "INVITE ONLY"
VISIBILITY_CHOICES = [
(PRIVATE, "Private"),
(PUBLIC, "Public"),
(INVITE_ONLY, "Invite Only"),
]
ROLE_SELECT = "ROLE_SELECT"
SIG_SELECT = "SIG_SELECT"
SKY_SELECT = "SKY_SELECT"
IN_GAME = "IN_GAME"
TABLE_STATUS_CHOICES = [
(ROLE_SELECT, "Role Select"),
(SIG_SELECT, "Significator Select"),
(SKY_SELECT, "Sky Select"),
(IN_GAME, "In Game"),
]
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=200)
owner = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_rooms"
)
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
table_status = models.CharField(
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
)
sig_select_started_at = models.DateTimeField(null=True, blank=True)
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
created_at = models.DateTimeField(auto_now_add=True)
board_state = models.JSONField(default=dict)
seed_count = models.IntegerField(default=12)
class GateSlot(models.Model):
EMPTY = "EMPTY"
RESERVED = "RESERVED"
FILLED = "FILLED"
STATUS_CHOICES = [
(EMPTY, "Empty"),
(RESERVED, "Reserved"),
(FILLED, "Filled"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="gate_slots")
slot_number = models.IntegerField()
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="gate_slots"
)
funded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="funded_slots"
)
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
reserved_at = models.DateTimeField(null=True, blank=True)
filled_at = models.DateTimeField(null=True, blank=True)
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
class RoomInvite(models.Model):
PENDING = "PENDING"
ACCEPTED = "ACCEPTED"
DECLINED = "DECLINED"
STATUS_CHOICES = [
(PENDING, "Pending"),
(ACCEPTED, "Accepted"),
(DECLINED, "Declined"),
]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="invites")
inviter = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="sent_invites"
)
invitee_email = models.EmailField()
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
created_at = models.DateTimeField(auto_now_add=True)
@receiver(post_save, sender=Room)
def create_gate_slots(sender, instance, created, **kwargs):
if created:
for i in range(1, 7):
GateSlot.objects.create(room=instance, slot_number=i)
def select_token(user):
"""Pick a token for `drop_token`'s rails-click flow (no explicit
kit-bag choice). Equip-gated: trinkets (PASS/BAND/COIN) must be DON-ed
to fire; CARTE is opt-in only (kit-bag click sets a `token_id` POST
param that bypasses this picker). No equipped trinket OR equipped
trinket invalid for this gate → fall back to FREE (FEFO) → TITHE → None.
Bug 2026-05-21 fix: previous flat-priority chain (PASS → BAND → COIN
→ FREE → TITHE, regardless of equip state) silently consumed a DOFFed
COIN — user saw nothing change in the wallet ("free for all" symptom).
Equip slot now gates trinket use entirely. See [[feedback-equip-slot-
gates-trinket-use]] for the rationale.
"""
# Query the trinket fresh from the user's tokens (not via the cached
# FK descriptor) — defensive against stale Token state from earlier
# in the request lifecycle + cheap filter on the owned-set so a
# dangling FK to a deleted token resolves to None instead of crashing.
if user.equipped_trinket_id is not None:
trinket = user.tokens.filter(pk=user.equipped_trinket_id).first()
else:
trinket = None
if trinket is not None:
if trinket.token_type == Token.PASS and user.is_staff:
return trinket
if trinket.token_type == Token.BAND:
return trinket
if trinket.token_type == Token.COIN and trinket.current_room_id is None:
return trinket
# CARTE excluded — opt-in via explicit kit-bag click; idle CARTE-
# holders get FREE/TITHE fallback.
free = user.tokens.filter(
token_type=Token.FREE,
expires_at__gt=timezone.now(),
).order_by("expires_at").first()
if free:
return free
return user.tokens.filter(token_type=Token.TITHE).first()
def debit_token(user, slot, token):
slot.debited_token_type = token.token_type
if token.token_type == Token.COIN:
token.current_room = slot.room
period = slot.room.renewal_period or timedelta(days=7)
token.next_ready_at = timezone.now() + period
token.save()
# Parity w. CARTE's drop_token unequip: a deposited COIN is committed
# elsewhere & can't be re-used as the active trinket until the deposit
# is released, so clear `equipped_trinket` to drop it out of the Kit
# Bag's Trinket slot. PASS stays equipped (auto-admits, never deposits).
if user.equipped_trinket_id == token.pk:
user.equipped_trinket = None
user.save(update_fields=["equipped_trinket"])
elif token.token_type == Token.CARTE:
pass # current_room already set in drop_token; token not consumed
elif token.token_type not in (Token.PASS, Token.BAND):
slot.debited_token_expires_at = token.expires_at
token.delete()
slot.gamer = user
slot.status = GateSlot.FILLED
slot.filled_at = timezone.now()
slot.save()
room = slot.room
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
room.gate_status = Room.OPEN
room.save()
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
class TableSeat(models.Model):
PC = "PC"
BC = "BC"
SC = "SC"
AC = "AC"
NC = "NC"
EC = "EC"
ROLE_CHOICES = [
(PC, "Player"),
(BC, "Builder"),
(SC, "Shepherd"),
(AC, "Alchemist"),
(NC, "Narrator"),
(EC, "Economist"),
]
PARTNER_MAP = {PC: BC, BC: PC, SC: AC, AC: SC, NC: EC, EC: NC}
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="table_seats")
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="table_seats"
)
slot_number = models.IntegerField()
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
role_revealed = models.BooleanField(default=False)
seat_position = models.IntegerField(null=True, blank=True)
significator = models.ForeignKey(
"TarotCard", null=True, blank=True,
on_delete=models.SET_NULL, related_name="significator_seats",
)
deck_variant = models.ForeignKey(
"DeckVariant", null=True, blank=True,
on_delete=models.SET_NULL, related_name="active_seats",
)
class DeckVariant(models.Model):
"""A 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"),
]
# Per-family translation tables: canonical SUIT enum (Earthman vocab) →
# family-authentic display slug used in image filenames + UI labels.
# See [[reference-card-image-naming-convention]] v2.
_SUIT_SLUG_BY_FAMILY = {
EARTHMAN: {"BRANDS": "brands", "CROWNS": "crowns", "GRAILS": "grails", "BLADES": "blades"},
ITALIAN: {"BRANDS": "batons", "CROWNS": "coins", "GRAILS": "cups", "BLADES": "swords"},
ENGLISH: {"BRANDS": "wands", "CROWNS": "pentacles", "GRAILS": "cups", "BLADES": "swords"},
PLAYING: {"BRANDS": "clubs", "CROWNS": "diamonds", "GRAILS": "hearts", "BLADES": "spades"},
}
_TRUMP_CATEGORY_BY_FAMILY = {
EARTHMAN: "trumps",
ITALIAN: "trumps",
ENGLISH: "majors",
PLAYING: None, # 52-card decks: no trump category (jokers handled separately)
}
name = models.CharField(max_length=100, unique=True)
slug = models.SlugField(unique=True)
card_count = models.IntegerField()
description = models.TextField(blank=True)
is_default = models.BooleanField(default=False)
family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN)
has_card_images = models.BooleanField(default=True)
is_polarized = models.BooleanField(default=False)
@property
def variant_dir_slug(self):
"""Subdirectory under `cards-faces/<family>/` for this deck's images.
Strips family-implied prefixes from `slug` (e.g., RWS slug is
`tarot-rider-waite-smith` but lives at `english/rider-waite-smith/` —
the "tarot-" is redundant under family=english). Earthman is special-
cased to "default" per user-locked spec 2026-05-26: even though it's
currently a single canonical deck, we lock in the variant tier now
so future Earthman editions slot in alongside as `earthman/<variant>/`
w.o. a path migration.
Mapping today:
earthman / earthman → earthman/default
italian / minchiate-... → italian/minchiate-fiorentine-1860-1890
english / tarot-rws → english/rider-waite-smith (strip "tarot-")
"""
if self.family == self.EARTHMAN:
return "default"
if self.slug.startswith("tarot-"):
return self.slug[len("tarot-"):]
return self.slug
@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.family}/{self.variant_dir_slug}/{self.slug}-back.png"
)
def suit_slug(self, canonical_suit):
"""Map canonical SUIT enum → family-authentic filename slug.
e.g. ('italian', 'BRANDS') → 'batons'."""
return self._SUIT_SLUG_BY_FAMILY[self.family][canonical_suit]
def suit_display(self, canonical_suit):
"""User-facing capitalized suit label, e.g. ('italian', 'BRANDS') → 'Batons'."""
return self.suit_slug(canonical_suit).capitalize()
@property
def trump_category(self):
"""Filename-slug category for trump cards in this family."""
return self._TRUMP_CATEGORY_BY_FAMILY[self.family]
@property
def short_key(self):
"""First dash-separated word of slug — used as an HTML id component."""
return self.slug.split('-')[0]
def __str__(self):
return f"{self.name} ({self.card_count} cards)"
class TarotCard(models.Model):
MAJOR = "MAJOR"
MINOR = "MINOR" # pip cards (numbers 1-10)
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K, numbers 11-14)
ARCANA_CHOICES = [
(MAJOR, "Major Arcana"),
(MINOR, "Minor Arcana"),
(MIDDLE, "Middle Arcana"),
]
# Canonical SUIT_CHOICES = Earthman vocabulary (2026-05-25 lock).
# Per-family display + filename slug mapping lives in image_filename /
# display_suit_name properties driven by DeckVariant.family.
BRANDS = "BRANDS"
CROWNS = "CROWNS"
GRAILS = "GRAILS"
BLADES = "BLADES"
SUIT_CHOICES = [
(BRANDS, "Brands"),
(CROWNS, "Crowns"),
(GRAILS, "Grails"),
(BLADES, "Blades"),
]
deck_variant = models.ForeignKey(
DeckVariant, null=True, blank=True,
on_delete=models.CASCADE, related_name="cards",
)
name = models.CharField(max_length=200)
arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana)
number = models.IntegerField() # 021 major (Fiorentine); 049 major (Earthman); 114 minor
slug = models.SlugField(max_length=120)
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # polysemous (cf [[feedback-reversal-qualifier-dual-role]]): on non-Majors w. no polarity qualifier it's the reversal-face qualifier (e.g. "Vacant"); on Majors w. polarity qualifiers it's the NAME-SWAP for the reversal face (e.g. "Patrilineage" for card 34). `applet_face()` routes on `arcana`.
reversal_drops_qualifier = models.BooleanField(default=False) # Pattern B' cards (16-18): reversal face shows the name swap ALONE, no qualifier. Pattern B (default False): polarity qualifier persists on the reversal face.
levity_qualifier = models.CharField(max_length=100, blank=True, default='')
gravity_qualifier = models.CharField(max_length=100, blank=True, default='')
levity_emanation = models.CharField(max_length=200, blank=True, default='') # polarity-split upright (cards 48-49)
gravity_emanation = models.CharField(max_length=200, blank=True, default='')
levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48)
gravity_reversal = models.CharField(max_length=200, blank=True, default='')
italic_word = models.CharField(max_length=50, blank=True, default='') # word(s) inside any title slot to wrap in <em> at render time (e.g. "Stalking" for trumps 19-21)
energies = models.JSONField(default=list) # list of {type, effect} dicts — Energy interactions
operations = models.JSONField(default=list) # list of {type, effect} dicts — Operation interactions
keywords_upright = models.JSONField(default=list)
keywords_reversed = models.JSONField(default=list)
cautions = models.JSONField(default=list)
class Meta:
ordering = ["deck_variant", "arcana", "suit", "number"]
unique_together = [("deck_variant", "slug")]
# Per-trump overrides for Fiorentine Minchiate fidelity — the historical
# deck art uses additive numerals at these specific ranks only (NOT every
# 4/9 ending; e.g. trump 9 = IX, trump 14 = XIV stay subtractive per the
# actual printed cards). Earthman's 0-49 trumps inherit the same mapping
# for visual consistency w. the Fiorentine deck. Other ranks fall through
# to the standard subtractive `_to_roman` algorithm.
_FIORENTINE_ADDITIVE_NUMERALS = {
4: 'IIII',
19: 'XVIIII',
24: 'XXIIII',
29: 'XXVIIII',
34: 'XXXIIII',
39: 'XXXVIIII',
}
@staticmethod
def _to_roman(n):
if n == 0:
return '0'
if n in TarotCard._FIORENTINE_ADDITIVE_NUMERALS:
return TarotCard._FIORENTINE_ADDITIVE_NUMERALS[n]
val = [50, 40, 10, 9, 5, 4, 1]
syms = ['L','XL','X','IX','V','IV','I']
result = ''
for v, s in zip(val, syms):
while n >= v:
result += s
n -= v
return result
@property
def corner_rank(self):
if self.arcana == self.MAJOR:
return self._to_roman(self.number)
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
if self.number in court:
return court[self.number]
return 'A' if self.number == 1 else str(self.number)
def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity').
Falls back to name_title (group prefix stripped) for cards without a
polarity split."""
if polarity == 'levity' and self.levity_emanation:
return self.levity_emanation
if polarity == 'gravity' and self.gravity_emanation:
return self.gravity_emanation
return self.name_title
def reversal_for(self, polarity):
"""Return the reversed title for a given polarity.
Falls back to reversal_qualifier (blank = same as emanation_for)."""
if polarity == 'levity' and self.levity_reversal:
return self.levity_reversal
if polarity == 'gravity' and self.gravity_reversal:
return self.gravity_reversal
return self.reversal_qualifier or self.emanation_for(polarity)
def applet_face(self, polarity='gravity', reversed=False):
"""Return the rendering payload for a card face in the My Sign /
My Sea applets — mirrors `populateCard` in `stage-card.js`. Four
patterns:
- **Polarity-split FULL title** (cards 19-21, 48-49): single-line
title from `emanation_for` / `reversal_for`; qualifier blank.
- **Pattern B — Major w. polarity qualifier + reversal name-swap**
(cards 2-5, 10-15, 22-35, 41): `reversal_qualifier` carries the
REVERSAL-face NAME (e.g. "Patrilineage" for card 34). Polarity
qualifier persists across both faces. Renders: `<reversal_qual>,`
/ `<polarity_qualifier>` on the reversal face.
- **Pattern B' — Major w. name-swap that DROPS qualifier on
reversal** (cards 16-18 — Realms): same as Pattern B but the
reversal face renders only the name (e.g. "Shame"), no
qualifier. Marked via `reversal_drops_qualifier=True`.
- **Non-Major (middle / minor)**: qualifier ABOVE title; reversal
face uses `reversal_qualifier` as the QUALIFIER (NOT a name
swap) — e.g. "Queen of Crowns" stays as the title, "Vacant"
renders as the reversal qualifier.
Returns a 3-key dict:
{
"title": str, # title (w. trailing comma for Major+qual)
"qualifier": str, # qualifier text (may be blank)
"qualifier_first": bool, # True ⇒ qualifier above title; False ⇒ below
}
"""
is_major = (self.arcana == self.MAJOR)
if reversed:
override = (self.levity_reversal if polarity == 'levity'
else self.gravity_reversal)
if override:
return {"title": override, "qualifier": "", "qualifier_first": False}
polarity_qualifier = (
self.levity_qualifier if polarity == 'levity'
else self.gravity_qualifier
)
# Pattern B / B' — Major w. both polarity qualifier + reversal
# name-swap. `reversal_qualifier` is the SWAPPED NAME (not a
# qualifier) for these Majors. See `reversal_qualifier` field
# docstring + [[feedback-reversal-qualifier-dual-role]].
if is_major and self.reversal_qualifier and polarity_qualifier:
if self.reversal_drops_qualifier:
# Pattern B' (16-18): single-line reversal name.
return {"title": self.reversal_qualifier,
"qualifier": "", "qualifier_first": False}
# Pattern B (2-5, 10-15, 22-35, 41): swapped name + polarity
# qualifier carried across both faces.
return {"title": self.reversal_qualifier + ",",
"qualifier": polarity_qualifier,
"qualifier_first": False}
# Non-Major OR Major-without-polarity-qualifier: reversal_
# qualifier is the qualifier (Pattern A / fallback).
qualifier = self.reversal_qualifier or polarity_qualifier
else:
override = (self.levity_emanation if polarity == 'levity'
else self.gravity_emanation)
if override:
return {"title": override, "qualifier": "", "qualifier_first": False}
qualifier = (self.levity_qualifier if polarity == 'levity'
else self.gravity_qualifier)
title = self.name_title
if is_major and qualifier:
return {"title": title + ",", "qualifier": qualifier,
"qualifier_first": False}
return {"title": title, "qualifier": qualifier, "qualifier_first": True}
@property
def name_group(self):
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
if ': ' in self.name:
return self.name.split(': ', 1)[0] + ':'
return ''
@property
def name_title(self):
"""Returns the title after 'Group N: ', or the full name if no colon."""
if ': ' in self.name:
return self.name.split(': ', 1)[1]
return self.name
@property
def title_squeeze_class(self):
"""No-op kept for template compatibility. Title fit is now handled by
a smaller base `font-size` on `.fan-card-name`/`.fan-card-reversal-*`
plus `text-wrap: balance` (see `_card-deck.scss`) — every long-title
card fits naturally without per-card CSS hacks."""
return ''
@property
def suit_icon(self):
if self.arcana == self.MAJOR:
# Trump 0 (Fool / Nomad / Matto) + trump 1 (Magician / Schizo /
# Bagatto) carry universal symbol overrides — cowboy-hat-side for
# the wanderer/fool archetype, wizard-hat for the magus archetype.
# Pinned BEFORE the `self.icon` branch so even a deck seed that
# supplies a different icon for these two ranks gets normalized
# to the convention (Earthman's seed already aligns; Minchiate's
# empty icon field used to fall through to fa-hand-dots).
if self.number == 0:
return 'fa-hat-cowboy-side'
if self.number == 1:
return 'fa-hat-wizard'
if self.icon:
return self.icon
if self.arcana == self.MAJOR:
# Sprint A.7.5 — trumps default to fa-hand-dots so the chip (and
# any text-mode corner) always has a symbol below the rank. Per-
# card overrides still win via the `self.icon` branch above (the
# Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps
# 2+, which was the only place this fallback used to live; trumps
# 2+ Minchiate trumps still pick it up for free here).
return 'fa-hand-dots'
return {
self.BRANDS: 'fa-wand-sparkles',
self.CROWNS: 'fa-crown',
self.GRAILS: 'fa-trophy',
self.BLADES: 'fa-gun',
}.get(self.suit, '')
# 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)
@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.
Path structure: `cards-faces/<family>/<variant_dir_slug>/<filename>`
per the family-grouped tree convention (user spec 2026-05-26). See
`DeckVariant.variant_dir_slug` for the variant subdir mapping.
"""
if not self.deck_variant.has_card_images:
return ""
from django.templatetags.static import static
deck = self.deck_variant
return static(
f"apps/epic/images/cards-faces/{deck.family}/{deck.variant_dir_slug}/{self.image_filename}"
)
@property
def cautions_json(self):
import json
return json.dumps(self.cautions)
@property
def energies_json(self):
import json
return json.dumps(self.energies)
@property
def operations_json(self):
import json
return json.dumps(self.operations)
def __str__(self):
return self.name
class TarotDeck(models.Model):
"""One shuffled deck per room, scoped to the founder's chosen DeckVariant."""
room = models.OneToOneField(Room, on_delete=models.CASCADE, related_name="tarot_deck")
deck_variant = models.ForeignKey(
DeckVariant, null=True, blank=True,
on_delete=models.SET_NULL, related_name="active_decks",
)
drawn_card_ids = models.JSONField(default=list)
created_at = models.DateTimeField(auto_now_add=True)
@property
def remaining_count(self):
total = self.deck_variant.card_count if self.deck_variant else 0
return total - len(self.drawn_card_ids)
def draw(self, n=1):
"""Draw n cards at random. Returns list of (TarotCard, reversed: bool) tuples."""
available = list(
TarotCard.objects.filter(deck_variant=self.deck_variant)
.exclude(id__in=self.drawn_card_ids)
)
if len(available) < n:
raise ValueError(
f"Not enough cards remaining: {len(available)} available, {n} requested"
)
drawn = random.sample(available, n)
self.drawn_card_ids = self.drawn_card_ids + [card.id for card in drawn]
self.save(update_fields=["drawn_card_ids"])
return [(card, random.choice([True, False])) for card in drawn]
def shuffle(self):
"""Reset the deck so all variant cards are available again."""
self.drawn_card_ids = []
self.save(update_fields=["drawn_card_ids"])
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
class SigReservation(models.Model):
LEVITY = 'levity'
GRAVITY = 'gravity'
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
gamer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
)
seat = models.ForeignKey(
'TableSeat', null=True, blank=True,
on_delete=models.SET_NULL, related_name='sig_reservation',
)
card = models.ForeignKey(
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
)
role = models.CharField(max_length=2)
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
reserved_at = models.DateTimeField(auto_now_add=True)
ready = models.BooleanField(default=False)
countdown_remaining = models.IntegerField(null=True, blank=True)
class Meta:
constraints = [
UniqueConstraint(
fields=['room', 'gamer'],
name='one_sig_reservation_per_gamer_per_room',
),
UniqueConstraint(
fields=['room', 'card', 'polarity'],
name='one_reservation_per_card_per_polarity_per_room',
),
]
# ── Significator deck helpers ─────────────────────────────────────────────────
def _room_deck_variant(room):
"""Return the DeckVariant in use for this room.
Looks up the deck committed to any TableSeat in the room (all seats share the
same deck per game). Falls back to the room owner's equipped_deck for rooms
created before deck contribution was wired.
"""
seat = room.table_seats.filter(deck_variant__isnull=False).first()
if seat:
return seat.deck_variant
return room.owner.equipped_deck
def sig_deck_cards(room):
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
PC/BC pair → BRANDS + CROWNS Middle Arcana court cards (1114): 8 unique
SC/AC pair → BLADES + GRAILS Middle Arcana court cards (1114): 8 unique
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
"""
unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room))
return unique_cards + unique_cards # × 2 = 36
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
after room → deck_variant lookup) and the solo My Sign picker (called
via personal_sig_cards from User.equipped_deck).
"Court cards" are recognized by rank (11=Page, 12=Knight, 13=Queen,
14=King) regardless of arcana classification: Earthman classifies its
courts as MIDDLE arcana, but other tarot families (Minchiate Fiorentine,
RWS) classify them as MINOR. Including both classifications gives every
deck the symmetric 18-card pile (16 courts × 4 suits + 2 majors at
numbers 0/1) instead of letting non-Earthman decks fall to 2 cards just
because they don't use the MIDDLE classification. Cross-deck eligibility
is NOT segment-limited — all 4 suits' courts qualify per user spec
2026-05-25.
"""
if deck_variant is None:
return []
courts = list(TarotCard.objects.filter(
deck_variant=deck_variant,
arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS],
number__in=[11, 12, 13, 14],
))
major = list(TarotCard.objects.filter(
deck_variant=deck_variant,
arcana=TarotCard.MAJOR,
number__in=[0, 1],
))
return courts + major
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
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)
def _filter_major_unlocks(cards, user):
"""Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
if user is None or not user.is_authenticated:
return [c for c in cards if c.arcana != TarotCard.MAJOR]
earned = set(user.notes.values_list("slug", flat=True))
return [
c for c in cards
if c.arcana != TarotCard.MAJOR
or (c.number == 0 and earned & {"nomad", "super-nomad"})
or (c.number == 1 and earned & {"schizo", "super-schizo"})
]
def levity_sig_cards(room, user=None):
"""Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks."""
return _filter_major_unlocks(_sig_unique_cards(room), user)
def gravity_sig_cards(room, user=None):
"""Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks."""
return _filter_major_unlocks(_sig_unique_cards(room), user)
def sig_seat_order(room):
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
seats = list(room.table_seats.all())
return sorted(seats, key=lambda s: _order.get(s.role, 99))
def active_sig_seat(room):
"""Return the first seat without a significator in canonical order, or None."""
for seat in sig_seat_order(room):
if seat.significator_id is None:
return seat
return None
# ── Astrological reference tables (seeded, never user-edited) ─────────────────
class Sign(models.Model):
FIRE = 'Fire'
EARTH = 'Earth'
AIR = 'Air'
WATER = 'Water'
ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)]
CARDINAL = 'Cardinal'
FIXED = 'Fixed'
MUTABLE = 'Mutable'
MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)]
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ♈ ♉ … ♓
element = models.CharField(max_length=5, choices=ELEMENT_CHOICES)
modality = models.CharField(max_length=8, choices=MODALITY_CHOICES)
order = models.PositiveSmallIntegerField(unique=True) # 011, Aries first
start_degree = models.FloatField() # 0, 30, 60 … 330
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class Planet(models.Model):
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
order = models.PositiveSmallIntegerField(unique=True) # 09, Sun first
class Meta:
ordering = ['order']
def __str__(self):
return self.name
class AspectType(models.Model):
name = models.CharField(max_length=20, unique=True)
symbol = models.CharField(max_length=5) # ☌ ⚹ □ △ ☍
angle = models.PositiveSmallIntegerField() # 0, 60, 90, 120, 180
orb = models.FloatField() # max allowed orb in degrees
class Meta:
ordering = ['angle']
def __str__(self):
return self.name
class HouseLabel(models.Model):
"""Life-area label for each of the 12 astrological houses (distinctions)."""
number = models.PositiveSmallIntegerField(unique=True) # 112
name = models.CharField(max_length=30)
keywords = models.CharField(max_length=100, blank=True)
class Meta:
ordering = ['number']
def __str__(self):
return f"{self.number}: {self.name}"
# ── Character ─────────────────────────────────────────────────────────────────
class Character(models.Model):
"""A gamer's player-character for one seat in one game session.
Lifecycle:
- Created (draft) when gamer opens CAST SKY overlay.
- 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',
)
# ── significator (set at CAST SKY) ────────────────────────────────────
significator = models.ForeignKey(
TarotCard, null=True, blank=True,
on_delete=models.SET_NULL, related_name='character_significators',
)
# ── sky input (what the gamer entered) ─────────────────────────────
birth_dt = models.DateTimeField(null=True, blank=True) # UTC
birth_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
birth_lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
birth_place = models.CharField(max_length=200, blank=True) # display string only
house_system = models.CharField(
max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY,
)
# ── computed sky snapshot (full PySwiss response) ───────────────────
chart_data = models.JSONField(null=True, blank=True)
# ── celtic cross spread (added at DRAW SEA) ───────────────────────────
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