Files
python-tdd/src/apps/epic/models.py
Disco DeDisco 8dd4347dbe
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
fix: gate token-picker now equip-gated — User.equipped_trinket is the sole opt-in for trinket-as-token use at BOTH gatekeepers (/gameboard/room/<id>/gate/ + /gameboard/my-sea/gate/). Old flat-priority chain (PASS→BAND→COIN→FREE→TITHE) silently consumed a DOFFed-but-owned COIN when the user clicked the rails — current_room advanced, no inventory decrement, wallet looked unchanged. User-reported 2026-05-21 as "free for all" admit when no trinket equipped. Root cause: select_token + _select_my_sea_token ignored equipped_trinket_id entirely + just grabbed the highest-priority owned token regardless of equip state, making the equip slot a decorative no-op. **Fix**: both pickers now start from user.equipped_trinket_id; equipped PASS (staff)/BAND/COIN-with-no-current-room → return it; equipped CARTE → fall through (CARTE is opt-in via kit-bag click that sets token_id POST param routed through drop_token's explicit branch, NOT select_token); my-sea additionally checks COIN cooldown (next_ready_at <= now); no equipped trinket OR equipped trinket invalid → FREE (FEFO) → TITHE → None. **Fresh-query defense**: pickers query user.tokens.filter(pk=user.equipped_trinket_id).first() instead of the cached user.equipped_trinket FK descriptor — descriptor goes stale across mid-request state changes + bites tests where tokens.all().delete() triggers SET_NULL cascade but the Python object stays unrefreshed (SQLite reuses deleted PKs so a coincidentally-matching new token slips through). TDD — new SelectTokenEquipGatedTest (7 ITs) + SelectMySeaTokenEquipGatedTest (6 ITs) pin: skip-unequipped-COIN → FREE; skip-unequipped-BAND → TITHE; no equip + no consumables → None; CARTE equipped → falls through; equipped-COIN-in-use-elsewhere falls through; staff with unequipped PASS falls through; my-sea cooldown-COIN-equipped falls through. **Existing tests updated** (5 cases pinned the old flat-priority semantic + needed equipping explicit before assertion): SelectTokenTest.test_returns_pass_for_staff + test_returns_band_when_equipped + test_pass_wins_when_equipped_over_band + SelectMySeaTokenTest.test_pass_wins_priority_for_staff (now equip PASS first); ConfirmTokenPriorityViewTest.test_pass_not_consumed_and_coin_not_leased + TokenPriorityTest.test_staff_backstage_pass_bypasses_token_cost (FT) now DON the PASS before clicking rails. SelectMySeaTokenTest.setUp adds refresh_from_db() after tokens.all().delete() so the cascade SET_NULL on equipped_trinket_id is reflected in the Python object. 1160 IT/UT + 5 TokenPriority FTs green. Trap captured: [[feedback-equip-slot-gates-trinket-use]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:56:59 -04:00

742 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (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" # 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"),
]
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() # 021 major (Fiorentine); 049 major (Earthman); 114 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='') # reversal-axis qualifier (e.g. "Nervous"); polarity-shared; blank = falls back to current polarity's qualifier
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 <em> 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")]
@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'}
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)
@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.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)
@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/WANDS + CROWNS Middle Arcana court cards (1114): 8 unique
SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (1114): 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_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 Sig picker (called
via personal_sig_cards from User.equipped_deck)."""
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 _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) # 011, 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) # 09, 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) # 112
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