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) def get_thread_post(self): """Get-or-create this room's single game-table thread Post (the POST view of the game-views carousel — [[project-room-game-views-carousel]]). Owner = room.owner, kind = KIND_ROOM_THREAD, title = the room name (truncated to Post.title's 35-char cap). Lazy import keeps epic free of a load-time billboard dependency (billboard already imports epic.Room).""" from apps.billboard.models import Post post, _ = Post.objects.get_or_create( room=self, kind=Post.KIND_ROOM_THREAD, defaults={"owner": self.owner, "title": self.name[:35]}, ) return post 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) # Per-slot token EXPENDITURE count — how many tokens this seat cost to # occupy. 1 today for every slot (a CARTE covers each seat at cost 1, like # any other token); only rises above 1 once the rising-game-cost feature # lands (a gamer in their 4th+ simultaneous game pays an elevated per-slot # cost). Lives HERE (the slot), not derived from the occupant's token — # the prior CARTE `slots_claimed` high-water mark wrongly showed N per seat. token_cost = models.PositiveSmallIntegerField(default=1) # ── Seat-occupancy / renewal clock (sprint 2026-05-31) ──────────────── # A filled seat's token cost is "current" for one renewal span after # `filled_at`, then sits in a renewal-grace span of equal length before # auto-BYE. Uniform across token types (no exceptions) — keyed on # `filled_at` only; the per-token `debit_token` rules are untouched. A # NULL `filled_at` (ORM fixtures / RESERVED slots) reads current / # never-expired so nothing built without a fill timestamp gets evicted. @property def renewal_span(self): return self.room.renewal_period or timedelta(days=7) @property def cost_current_until(self): """End of the cost-current window [A, A+S). None if not filled.""" if self.filled_at is None: return None return self.filled_at + self.renewal_span @property def grace_expires_at(self): """End of the renewal-grace window [A+S, A+2S) — the auto-BYE threshold. None if not filled.""" if self.filled_at is None: return None return self.filled_at + 2 * self.renewal_span @property def cost_current(self): """True in [A, A+S). NULL filled_at → True (never-filled / fixtures).""" until = self.cost_current_until return until is None or timezone.now() < until @property def in_renewal_grace(self): """True in [A+S, A+2S) — cost lapsed but the seat is still held for renewal. False before the span and after grace expires.""" if self.filled_at is None: return False return self.cost_current_until <= timezone.now() < self.grace_expires_at @property def grace_expired(self): """True at/after A+2S — past renewal grace, eligible for auto-BYE.""" exp = self.grace_expires_at return exp is not None and timezone.now() >= exp 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 token.in_use_since = timezone.now() # room in-use clock (7d release) 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) # When True, this deck is offered FREE ($0) in the wallet Shop applet — # a one-click claim adds it to the user's `unlocked_decks` (no Stripe). # Seeded True for RWS + Minchiate Fiorentine; Earthman stays False (it's # auto-granted at signup, not shopped). See migration 0016. free_in_shop = models.BooleanField(default=False) @property def variant_dir_slug(self): """Subdirectory under `cards-faces//` 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//` 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() # 0–21 major (Fiorentine); 0–49 major (Earthman); 1–14 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 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: `,` / `` 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 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: --[-].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///` 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', 'seat'], name='one_sig_reservation_per_gamer_per_seat', ), 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 # ── Dubbodeck assembly ──────────────────────────────────────────────────── # Each polarity's 18-card sig pile is assembled from THREE seats, each # contributing ONE segment FROM THAT SEAT'S OWN deck (user-locked 2026-06-03): # • CROWNS/BRANDS courts (8) ← the `cb` role's seat deck # • GRAILS/BLADES courts (8) ← the `gb` role's seat deck # • TRUMPS majors 0,1 (2) ← the `tr` role's seat deck # so an RWS King of Grails (from an SC seat) can sit beside a Minchiate Queen of # Wands (from a PC seat) in the same levity pile, each rendering its own deck's # face/back art (each card already carries `deck_variant`, so NO schema change). # A missing seat/deck falls back to `_room_deck_variant`, so a single-deck (or # CARTE-solo) room assembles the identical 18-card pile it always did. _POLARITY_SEGMENT_ROLES = { "levity": {"cb": "PC", "gb": "SC", "tr": "NC"}, "gravity": {"cb": "BC", "gb": "AC", "tr": "EC"}, } def _seat_deck_for_role(room, role): """The deck contributed by the seat holding `role`; falls back to the room deck when that seat (or its deck) is missing (single-deck / CARTE-solo).""" seat = room.table_seats.filter(role=role).first() if seat and seat.deck_variant_id: return seat.deck_variant return _room_deck_variant(room) def _court_cards(deck_variant, suits): """Court cards (rank 11–14) of the given suits from a deck. Courts are keyed by RANK, not arcana class — Earthman classes them MIDDLE, RWS/Minchiate MINOR — so both classifications qualify (mirrors `_sig_unique_cards_for_deck`).""" if deck_variant is None: return [] return list(TarotCard.objects.filter( deck_variant=deck_variant, arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR], suit__in=suits, number__in=[11, 12, 13, 14], ).select_related("deck_variant")) def _major_cards(deck_variant): """The two sig majors (Nomad 0, Schizo 1) from a deck.""" if deck_variant is None: return [] return list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MAJOR, number__in=[0, 1], ).select_related("deck_variant")) def _polarity_sig_cards(room, polarity): """Assemble one polarity's 18-card dubbodeck pile, note-UNFILTERED, in the layout order C/B courts (8) → G/B courts (8) → majors (2).""" roles = _POLARITY_SEGMENT_ROLES[polarity] return ( _court_cards(_seat_deck_for_role(room, roles["cb"]), [TarotCard.BRANDS, TarotCard.CROWNS]) + _court_cards(_seat_deck_for_role(room, roles["gb"]), [TarotCard.GRAILS, TarotCard.BLADES]) + _major_cards(_seat_deck_for_role(room, roles["tr"])) ) def sig_deck_cards(room): """The full sig validation set — BOTH polarity piles (note-UNFILTERED), so `select_sig` accepts a card from EITHER deck/polarity. For a single-deck room every segment resolves to the same deck, so this is 18 + 18 = 36 (each pile's 18 doubled across the two polarities) with the historic suit/arcana split.""" return _polarity_sig_cards(room, "levity") + _polarity_sig_cards(room, "gravity") 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 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): """The levity dubbodeck pile (PC crowns/brands · SC grails/blades · NC majors), filtered by the user's Note unlocks (Nomad/Schizo majors).""" return _filter_major_unlocks(_polarity_sig_cards(room, "levity"), user) def gravity_sig_cards(room, user=None): """The gravity dubbodeck pile (BC crowns/brands · AC grails/blades · EC majors), filtered by the user's Note unlocks (Nomad/Schizo majors).""" return _filter_major_unlocks(_polarity_sig_cards(room, "gravity"), 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) # 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: - 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