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): if user.is_staff: pass_token = user.tokens.filter(token_type=Token.PASS).first() if pass_token: return pass_token coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first() if coin: return coin 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() elif token.token_type == Token.CARTE: pass # current_room already set in drop_token; token not consumed elif token.token_type != Token.PASS: 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 (108 cards) or Fiorentine Minchiate (78 cards).""" 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) @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" MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K) ARCANA_CHOICES = [ (MAJOR, "Major Arcana"), (MINOR, "Minor Arcana"), (MIDDLE, "Middle Arcana"), ] WANDS = "WANDS" CUPS = "CUPS" SWORDS = "SWORDS" PENTACLES = "PENTACLES" # Fiorentine 4th suit CROWNS = "CROWNS" # Earthman 4th suit BRANDS = "BRANDS" # Earthman Wands GRAILS = "GRAILS" # Earthman Cups BLADES = "BLADES" # Earthman Swords SUIT_CHOICES = [ (WANDS, "Wands"), (CUPS, "Cups"), (SWORDS, "Swords"), (PENTACLES, "Pentacles"), (CROWNS, "Crowns"), (BRANDS, "Brands"), (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 = models.CharField(max_length=200, blank=True, default='') # reversed-state title; blank = same as name 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='') mechanisms = models.JSONField(default=list) # list of dicts; in-game effects articulations = models.JSONField(default=list) # list of dicts; combinatory effects 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")] @staticmethod def _to_roman(n): if n == 0: return '0' val = [50, 40, 10, 9, 5, 4, 1] syms = ['L','XL','X','IX','V','IV','I'] result = '' for v, s in zip(val, syms): while n >= v: result += s n -= v return result @property def corner_rank(self): if self.arcana == self.MAJOR: return self._to_roman(self.number) court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'} return court.get(self.number, str(self.number)) def emanation_for(self, polarity): """Return the upright title for a given polarity ('levity' or 'gravity'). Falls back to name 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 def reversal_for(self, polarity): """Return the reversed title for a given polarity. Falls back to reversal (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 or self.emanation_for(polarity) @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 suit_icon(self): if self.icon: return self.icon if self.arcana == self.MAJOR: return '' return { self.WANDS: 'fa-wand-sparkles', self.CUPS: 'fa-trophy', self.SWORDS: 'fa-gun', self.PENTACLES: 'fa-star', self.CROWNS: 'fa-crown', self.BRANDS: 'fa-wand-sparkles', self.GRAILS: 'fa-trophy', self.BLADES: 'fa-gun', }.get(self.suit, '') @property def cautions_json(self): import json return json.dumps(self.cautions) 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/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique SC/AC pair → BLADES/SWORDS + GRAILS/CUPS 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. """ deck_variant = _room_deck_variant(room) if deck_variant is None: return [] wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MAJOR, number__in=[0, 1], )) unique_cards = wands_crowns + swords_cups + major # 18 unique return unique_cards + unique_cards # × 2 = 36 def _sig_unique_cards(room): """Return the 18 unique TarotCard objects that form one sig pile.""" deck_variant = _room_deck_variant(room) if deck_variant is None: return [] wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, 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 wands_crowns + swords_cups + major 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 "nomad" in earned) or (c.number == 1 and "schizo" in earned) ] 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 PICK 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 PICK SKY) ──────────────────────────────────── significator = models.ForeignKey( TarotCard, null=True, blank=True, on_delete=models.SET_NULL, related_name='character_significators', ) # ── natus 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 natus snapshot (full PySwiss response) ─────────────────── chart_data = models.JSONField(null=True, blank=True) # ── celtic cross spread (added at PICK 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