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 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" ) 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.""" 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}" ) @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 (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 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) # 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