import random import uuid from datetime import timedelta from django.db import models 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" IN_GAME = "IN_GAME" TABLE_STATUS_CHOICES = [ (ROLE_SELECT, "Role Select"), (SIG_SELECT, "Significator 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 ) 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() 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) 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" ARCANA_CHOICES = [ (MAJOR, "Major Arcana"), (MINOR, "Minor Arcana"), ] WANDS = "WANDS" CUPS = "CUPS" SWORDS = "SWORDS" PENTACLES = "PENTACLES" # Fiorentine 4th suit COINS = "COINS" # Earthman 4th suit (Ossum / Stone) SUIT_CHOICES = [ (WANDS, "Wands"), (CUPS, "Cups"), (SWORDS, "Swords"), (PENTACLES, "Pentacles"), (COINS, "Coins"), ] 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=5, choices=ARCANA_CHOICES) suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True) number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor slug = models.SlugField(max_length=120) correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent group = models.CharField(max_length=100, blank=True) # Earthman major grouping keywords_upright = models.JSONField(default=list) keywords_reversed = models.JSONField(default=list) class Meta: ordering = ["deck_variant", "arcana", "suit", "number"] unique_together = [("deck_variant", "slug")] 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"])