Five-thread sprint atop 53cd7af; all 1238 IT/UT green (no FTs run per [[feedback-ft-run-discipline]]).
**Thread 1 — User.significator_reversed is the POLARITY axis, not orientation.** The saved sig was rendering as a gravity reversal when the user saved a levity emanation. Root cause: `my_sign.html` JS post-save load called `_toggleOrientation()` whenever `revInput.value==='1'` (SPIN-ing a card whose flag only meant "polarity=levity"); `_applet-my-sign.html` applied `.stage-card--reversed` + `keywords_reversed` for the same flag. Fix: JS drops the `_toggleOrientation()` call (saved sigs are always upright in their polarity, never spun); the applet drops the rotation class, swaps to `my-sign-applet-card--{levity,gravity}` modifier, and always renders `keywords_upright` / "Emanation". `data-polarity` cascades correctly. Memory: [[feedback-significator-reversed-is-polarity]].
**Thread 2 — qualifier rendering on the My Sign + My Sea applets.** Both applets were rendering name only — no qualifier word. Added `TarotCard.applet_face(polarity, reversed)` (model method) + `User.sig_face` (delegator for the saved sig) returning `{title, qualifier, qualifier_first}` payload that mirrors `populateCard` in `stage-card.js`. `latest_draw_slots()` augments each slot dict w. `face`. Templates render `.fan-card-qualifier` + `.fan-card-name` in the order the payload dictates (non-Major: qualifier-above-title; Major+qualifier: title-with-trailing-comma above qualifier; polarity-split: single-line title). Typography matched to title (same bold, same size, same color via `color: inherit` w. polarity-pin at 0,3,0 specificity to beat `_card-deck.scss:376-383`'s 0,2,0 `.fan-card-face .fan-card-name` rule that out-cascades when loaded after gameboard).
**Thread 3 — My Sea cooldown bugs.** Two: (a) PAID DRAW button reverted to FREE DRAW after one navigation cycle because `my_sea_paid_draw` deleted the row at commit time — without a row, `quota_spent=False` on next render. (b) Brief's "next free draw at" was anchored to the most recent paid draw, not the original free draw. Fix: new `User.last_free_draw_at` field (set in `my_sea_lock` when a fresh row lands AND user wasn't already in cooldown — i.e., this is a tokenless free draw); paid draws NEVER touch it. New `MySeaDraw.paid_through_at` field stamped at commit time + cleared in `my_sea_lock` when the first card of the paid session lands (one-shot credit per user-spec: "each redraw needs a new token"). `my_sea_paid_draw` no longer deletes the row — clears hand+deposit, sets `paid_through_at`, redirects to `?phase=picker`. View's landing button uses `show_paid_draw` (`deposit_reserved OR paid_through_at`) so PAID DRAW persists across navigation until the paid session's first card lands. Brief reads `user.next_free_draw_at` (= `last_free_draw_at + 24h`) w. row-fallback for legacy test fixtures. 11 new ITs (`MySeaCooldownAnchoredToFreeDrawTest`, `UserFreeDrawCooldownPropertyTest`, expanded `MySeaPhasePickerQueryParamTest`, expanded `my_sea_lock` tests). Existing `test_paid_draw_deletes_active_draw_row` rewritten as `test_paid_draw_preserves_row_and_sets_paid_through_at`. 1 new FT pinning the navigation-persistence regression. Memory: [[feedback-my-sea-cooldown-design]].
**Thread 4 — Pattern B / B' Major reversal name-swap.** Card 34's My Sea applet rendered the reversal as "Animal Powers, Patrilineage" (Patrilineage treated as a qualifier). User-locked semantics: for Majors w. BOTH polarity qualifiers AND a `reversal_qualifier`, the `reversal_qualifier` field carries the NAME SWAP for the reversal face; the polarity qualifier persists across both faces. Affected cards: 2-5 (Pope/Horseman), 10-15 (Elements), 22-33 (Zodiac → Houses), 34-35 (Lunars), 41 (Asteroid Belt). Pattern B': cards 16-18 (Realms — Disco Inferno → Shame etc.) reversal face drops the qualifier entirely; new `TarotCard.reversal_drops_qualifier` BooleanField marks these (set True on 16-18 via `epic/0010_set_reversal_drops_qualifier_realms.py` data migration). `applet_face()` + `stage-card.js::populateCard` both branch on `arcana==MAJOR AND reversal_qualifier AND polarity_qualifier` → Pattern B/B' rendering. Non-Major `reversal_qualifier` semantics unchanged (middle court: "Queen of Crowns" stays as title, "Vacant" renders as the reversal-face qualifier). New data attr `data-reversal-drops-qualifier` added to `my_sign.html`, `_sig_select_overlay.html`, `_tarot_fan.html` so stage-card.js can read it via dataset. `card_dict()` extended w. the same field. 3 new UTs (`TarotCardAppletFaceTest`: Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin). Old `test_reversed_uses_reversal_qualifier_with_comma_for_major` deleted (it pinned the conflated old behavior).
**Thread 5 — unified card + stat-block polarity convention across all 6 surfaces** (Sig Select, Sea Select stage modal, Game Kit fan, My Sign applet, My Sea applet, room.html). User-locked: card and adjacent stat block always carry OPPOSITE-polarity bgs (gravity card --priUser → stat block --secUser; levity card --secUser → stat block --priUser). `.is-reversed` (SPIN) is preview-only — never shifts bg. Per-card scoping (NOT page-wide) — drawn sea cards each carry their own polarity from the deck stack; `.sea-stage--{gravity,levity}` parent rules + `.tarot-fan-wrap[data-polarity=...]` parent rules cascade to their respective stat blocks. `game-kit.js` `_populateStage` + `_flipActive` mirror `_polarity` onto `.tarot-fan-wrap` so SCSS can pick it up without touching the stat block directly. Sea-stat-block was previously stuck at --priUser regardless of polarity; fan-stage-block ditto. Both inverted now. Memory: [[feedback-card-polarity-convention]].
**Bundled polish across the same surfaces** (each one a small visible item the user spotted during the sprint):
- My Sign applet card: levity polarity flips bg to --secUser + border to --priUser + ink to --quiUser (matches page stage card at `_card-deck.scss:1002-1019`). Gravity stat block flips to --secUser bg w. --quiUser label ink + --priUser keyword ink (matches `_card-deck.scss:1042-1046`).
- Qualifier + title share typography (font-size, weight, polarity-color, text-wrap). `.fan-card-face { gap: 0 }` + `line-height: 1.15` so qualifier sits directly above title at the title's own line-height. `.fan-card-arcana { margin-top }` reserves breathing room below.
- `.fan-card-qualifier:empty { display: none }` collapses polarity-split / Major-no-qualifier cards cleanly.
**Memory recorded**:
1. [[feedback-ft-run-discipline]] — re-pinned 2026-05-23 after I burned a multi-minute full-FT-suite run mid-task. Default loop is IT/UT only. FT runs must be ONE test method by full dotted path; never a whole file; never re-run an already-green FT.
2. [[feedback-significator-reversed-is-polarity]] — the flag is polarity (FLIP), not orientation (SPIN); SPIN never persisted; saved sigs always upright in their polarity.
3. [[feedback-card-polarity-convention]] — opposite-polarity stat-block bg, per-card scoping, SPIN never shifts bg, the full color table.
4. [[feedback-my-sea-cooldown-design]] — cooldown anchored to User.last_free_draw_at, paid draws never reset it, paid_through_at is a sticky one-shot credit, button state machine.
**Files** (every uncommitted file folded in — session work + pre-existing modifications):
Models / migrations:
- `apps/epic/models.py` — `applet_face()` extended w. Pattern B/B' branches; new `reversal_drops_qualifier` BooleanField.
- `apps/epic/migrations/0009_reversal_drops_qualifier.py` — schema.
- `apps/epic/migrations/0010_set_reversal_drops_qualifier_realms.py` — data migration setting flag True on cards 16-18.
- `apps/epic/utils.py` — `card_dict` carries `reversal_drops_qualifier`.
- `apps/gameboard/models.py` — `paid_through_at` field; `latest_draw_slots()` attaches `face` payload per slot; `active_draw_for` docstring refreshed.
- `apps/gameboard/migrations/0003_myseadraw_paid_through_at.py` — schema.
- `apps/lyric/models.py` — `last_free_draw_at` field; `free_draw_cooldown_active` + `next_free_draw_at` props; `sig_face` delegator.
- `apps/lyric/migrations/0013_user_last_free_draw_at.py` — schema.
Views:
- `apps/gameboard/views.py` — `my_sea` view button state machine (`show_paid_draw` / `show_gate_view` / `show_picker`); `my_sea_lock` sets `last_free_draw_at` on free-draw + clears `paid_through_at` on paid-session first card; `my_sea_paid_draw` preserves row + stamps `paid_through_at`.
JS:
- `apps/epic/static/apps/epic/stage-card.js` — `fromDataset` reads `reversal_drops_qualifier`; `populateCard` branches Pattern B / B' for the reversal face.
- `apps/gameboard/static/apps/gameboard/game-kit.js` — mirrors `_polarity` onto `.tarot-fan-wrap` so SCSS can invert the fan-stage-block bg per active card.
Templates:
- `templates/apps/billboard/my_sign.html` — JS drops `_toggleOrientation()` on saved-sig load; sig-card grid carries `data-reversal-drops-qualifier`.
- `templates/apps/billboard/_partials/_applet-my-sign.html` — drops `stage-card--reversed`, adds polarity modifier, renders qualifier via `sig_face` payload, always shows Emanation keywords + label.
- `templates/apps/gameboard/_partials/_applet-my-sea.html` — renders qualifier via `slot.face` payload (Pattern B/B' aware).
- `templates/apps/gameboard/_partials/_sig_select_overlay.html` + `_tarot_fan.html` — `data-reversal-drops-qualifier` added to sig-card grid + fan cards.
- `templates/apps/gameboard/my_sea.html` — landing button form swaps to `show_paid_draw` / `show_gate_view` flags.
SCSS:
- `static_src/scss/_billboard.scss` — My Sign applet card polarity inversion (levity bg + ink), polarity stat-block inversion (gravity → --secUser bg), qualifier+title shared typography, polarity-aware ink via `color: inherit`.
- `static_src/scss/_card-deck.scss` — sea-stat-block polarity rules (`.sea-stage--gravity/levity .sea-stat-block`), fan-stage-block polarity rules (`.tarot-fan-wrap[data-polarity] .fan-stage-block`), comments documenting fallback bgs.
- `static_src/scss/_gameboard.scss` — `.my-sea-slot--filled.--gravity/--levity` pin `color: inherit` on `.fan-card-corner`, `.fan-card-qualifier`, `.fan-card-name`, `.fan-card-arcana` (0,3,0 beats global 0,2,0). Slot label keeps original wrap-sibling placement w. `z-index: 2` to render above the dotted bottom border on empty slots.
Tests:
- `apps/billboard/tests/integrated/test_views.py` — updated `test_my_sign_applet_renders_card_when_sig_set` to assert polarity modifier + qualifier text + Emanation-only; new `test_my_sign_applet_renders_gravity_qualifier_when_not_reversed`.
- `apps/epic/tests/unit/test_models.py` — `TarotCardAppletFaceTest` (Pattern B name swap, Pattern B' qualifier drop, non-Major regression pin, polarity-split, reversal qualifier fallback).
- `apps/gameboard/tests/integrated/test_views.py` — `MySeaCooldownAnchoredToFreeDrawTest` (5 tests pinning cooldown anchor on User, sticky PAID DRAW, paid-through credit consumption); `UserFreeDrawCooldownPropertyTest` (4 tests); expanded `MySeaPhasePickerQueryParamTest` w. paid-through-shows-PAID-DRAW-btn assertion; expanded `my_sea_lock` tests (free-draw-anchors-last_free_draw_at, paid-draw-leaves-anchor-alone, first-paid-card-consumes-credit); My Sea applet qualifier IT (Major comma format end-to-end).
- `functional_tests/test_game_my_sea.py` — `test_paid_draw_commits_token_and_redirects_to_picker` updated to assert row preservation + paid_through_at stamping; new `test_paid_draw_btn_persists_after_navigation_without_card_draw` pinning the user-reported regression.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
812 lines
32 KiB
Python
812 lines
32 KiB
Python
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() # 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 <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)
|
||
|
||
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: `<reversal_qual>,`
|
||
/ `<polarity_qualifier>` 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.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 (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_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) # 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
|