2026-03-24 21:07:01 -04:00
|
|
|
|
import random
|
2026-03-13 00:31:17 -04:00
|
|
|
|
import uuid
|
|
|
|
|
|
|
2026-03-13 17:31:52 -04:00
|
|
|
|
from datetime import timedelta
|
2026-03-12 15:05:02 -04:00
|
|
|
|
from django.db import models
|
2026-03-13 00:31:17 -04:00
|
|
|
|
from django.db.models.signals import post_save
|
|
|
|
|
|
from django.dispatch import receiver
|
|
|
|
|
|
from django.conf import settings
|
2026-03-13 17:31:52 -04:00
|
|
|
|
from django.utils import timezone
|
|
|
|
|
|
|
|
|
|
|
|
from apps.lyric.models import Token
|
2026-03-13 00:31:17 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Room(models.Model):
|
|
|
|
|
|
GATHERING = "GATHERING"
|
|
|
|
|
|
OPEN = "OPEN"
|
|
|
|
|
|
RENEWAL_DUE = "RENEWAL_DUE"
|
|
|
|
|
|
GATE_STATUS_CHOICES = [
|
2026-03-14 01:14:05 -04:00
|
|
|
|
(GATHERING, "GATHERING GAMERS"),
|
2026-03-13 00:31:17 -04:00
|
|
|
|
(OPEN, "Open"),
|
|
|
|
|
|
(RENEWAL_DUE, "Renewal Due"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
PRIVATE = "PRIVATE"
|
|
|
|
|
|
PUBLIC = "PUBLIC"
|
|
|
|
|
|
INVITE_ONLY = "INVITE ONLY"
|
|
|
|
|
|
VISIBILITY_CHOICES = [
|
|
|
|
|
|
(PRIVATE, "Private"),
|
|
|
|
|
|
(PUBLIC, "Public"),
|
|
|
|
|
|
(INVITE_ONLY, "Invite Only"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
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"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
|
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)
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
table_status = models.CharField(
|
|
|
|
|
|
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
|
|
|
|
|
)
|
2026-03-13 17:31:52 -04:00
|
|
|
|
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
2026-03-13 00:31:17 -04:00
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
|
|
board_state = models.JSONField(default=dict)
|
|
|
|
|
|
seed_count = models.IntegerField(default=12)
|
|
|
|
|
|
|
2026-03-13 18:37:19 -04:00
|
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
|
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)
|
2026-03-15 16:08:34 -04:00
|
|
|
|
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
|
|
|
|
|
|
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
|
2026-03-13 00:31:17 -04:00
|
|
|
|
|
2026-03-12 15:05:02 -04:00
|
|
|
|
|
2026-03-13 18:37:19 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 00:31:17 -04:00
|
|
|
|
@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)
|
2026-03-13 17:31:52 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-14 22:00:16 -04:00
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-03-13 17:31:52 -04:00
|
|
|
|
def debit_token(user, slot, token):
|
2026-03-15 16:08:34 -04:00
|
|
|
|
slot.debited_token_type = token.token_type
|
2026-03-13 17:31:52 -04:00
|
|
|
|
if token.token_type == Token.COIN:
|
|
|
|
|
|
token.current_room = slot.room
|
2026-03-13 22:51:42 -04:00
|
|
|
|
period = slot.room.renewal_period or timedelta(days=7)
|
|
|
|
|
|
token.next_ready_at = timezone.now() + period
|
2026-03-13 17:31:52 -04:00
|
|
|
|
token.save()
|
2026-03-16 00:07:52 -04:00
|
|
|
|
elif token.token_type == Token.CARTE:
|
|
|
|
|
|
pass # current_room already set in drop_token; token not consumed
|
2026-03-14 22:00:16 -04:00
|
|
|
|
elif token.token_type != Token.PASS:
|
2026-03-15 16:08:34 -04:00
|
|
|
|
slot.debited_token_expires_at = token.expires_at
|
2026-03-13 17:31:52 -04:00
|
|
|
|
token.delete()
|
|
|
|
|
|
slot.gamer = user
|
|
|
|
|
|
slot.status = GateSlot.FILLED
|
|
|
|
|
|
slot.filled_at = timezone.now()
|
|
|
|
|
|
slot.save()
|
2026-03-13 22:51:42 -04:00
|
|
|
|
|
|
|
|
|
|
room = slot.room
|
|
|
|
|
|
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
|
|
|
|
|
|
room.gate_status = Room.OPEN
|
|
|
|
|
|
room.save()
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-03-25 01:50:06 -04:00
|
|
|
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
|
|
|
|
|
|
|
|
|
|
|
|
Django Channels role-select sprint: turn_changed, roles_revealed, role_select_start consumer handlers; WS URL changed from room_slug to room_id UUID; TableSeat model - room, gamer, slot_number, role, role_revealed, seat_position fields; Room.table_status field with ROLE_SELECT, SIG_SELECT, IN_GAME choices; migration 0006_table_status_and_table_seat; pick_roles and select_role views; _role_select_context helper; _notify_turn_changed, _notify_roles_revealed, _notify_role_select_start notifiers; all gate-mutation views now call _notify_gate_update; ChannelsFunctionalTest base class with serve_static, screenshot, dump helpers; SQLite TEST NAME set to file path for ChannelsLiveServerTestCase; InMemoryChannelLayer added to test CHANNEL_LAYERS settings; FT 5 and FT 6 now passing - active seat arc and turn advance via WS, no page refresh; room.js, gatekeeper.js, role-select.js added to apps/epic/static; applets.js, game-kit.js, dashboard.js, wallet.js relocated to app-scoped static dirs; room.html: hex table, table-seat arcs, card-stack, inventory panel, role-card hand, WS scripts; _room.scss: room-shell flex layout, .table-hex polygon clip-path, .table-seat and .seat-card-arc, .card-stack eligible/ineligible states, .card flip animation, .inv-role-card stacked hand, .role-select-backdrop; gear btn and room menu always position: fixed; 375 tests, 0 skipped
2026-03-17 00:24:23 -04:00
|
|
|
|
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)
|
2026-03-25 01:50:06 -04:00
|
|
|
|
significator = models.ForeignKey(
|
|
|
|
|
|
"TarotCard", null=True, blank=True,
|
|
|
|
|
|
on_delete=models.SET_NULL, related_name="significator_seats",
|
|
|
|
|
|
)
|
2026-03-24 21:07:01 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-24 21:52:57 -04:00
|
|
|
|
@property
|
|
|
|
|
|
def short_key(self):
|
|
|
|
|
|
"""First dash-separated word of slug — used as an HTML id component."""
|
|
|
|
|
|
return self.slug.split('-')[0]
|
|
|
|
|
|
|
2026-03-24 21:07:01 -04:00
|
|
|
|
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
|
|
|
|
|
|
SUIT_CHOICES = [
|
|
|
|
|
|
(WANDS, "Wands"),
|
|
|
|
|
|
(CUPS, "Cups"),
|
|
|
|
|
|
(SWORDS, "Swords"),
|
|
|
|
|
|
(PENTACLES, "Pentacles"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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")]
|
|
|
|
|
|
|
2026-03-25 00:24:26 -04:00
|
|
|
|
@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))
|
|
|
|
|
|
|
2026-03-25 00:46:48 -04:00
|
|
|
|
@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
|
|
|
|
|
|
|
2026-03-25 00:24:26 -04:00
|
|
|
|
@property
|
|
|
|
|
|
def suit_icon(self):
|
|
|
|
|
|
if self.arcana == self.MAJOR:
|
|
|
|
|
|
return ''
|
|
|
|
|
|
return {
|
|
|
|
|
|
self.WANDS: 'fa-wand-sparkles',
|
|
|
|
|
|
self.CUPS: 'fa-trophy',
|
|
|
|
|
|
self.SWORDS: 'fa-gun',
|
2026-03-25 00:46:48 -04:00
|
|
|
|
self.PENTACLES: 'fa-star',
|
2026-03-25 00:24:26 -04:00
|
|
|
|
}.get(self.suit, '')
|
|
|
|
|
|
|
2026-03-24 21:07:01 -04:00
|
|
|
|
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"])
|
2026-03-25 01:50:06 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
def sig_deck_cards(room):
|
|
|
|
|
|
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
|
|
|
|
|
|
|
|
|
|
|
PC/BC pair → WANDS + PENTACLES court cards (numbers 11–14): 8 unique
|
|
|
|
|
|
SC/AC pair → SWORDS + CUPS court cards (numbers 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.owner.equipped_deck
|
|
|
|
|
|
if deck_variant is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
wands_pentacles = list(TarotCard.objects.filter(
|
|
|
|
|
|
deck_variant=deck_variant,
|
|
|
|
|
|
arcana=TarotCard.MINOR,
|
|
|
|
|
|
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES],
|
|
|
|
|
|
number__in=[11, 12, 13, 14],
|
|
|
|
|
|
))
|
|
|
|
|
|
swords_cups = list(TarotCard.objects.filter(
|
|
|
|
|
|
deck_variant=deck_variant,
|
|
|
|
|
|
arcana=TarotCard.MINOR,
|
|
|
|
|
|
suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
|
|
|
|
|
|
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_pentacles + swords_cups + major # 18 unique
|
|
|
|
|
|
return unique_cards + unique_cards # × 2 = 36
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|