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-04-05 22:01:23 -04:00
from django . db . models import UniqueConstraint
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 "
2026-04-09 01:17:24 -04:00
SKY_SELECT = " SKY_SELECT "
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
IN_GAME = " IN_GAME "
TABLE_STATUS_CHOICES = [
( ROLE_SELECT , " Role Select " ) ,
( SIG_SELECT , " Significator Select " ) ,
2026-04-09 01:17:24 -04:00
( SKY_SELECT , " Sky Select " ) ,
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
( 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-04-09 01:17:24 -04:00
sig_select_started_at = models . DateTimeField ( 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-04-27 22:38:07 -04:00
deck_variant = models . ForeignKey (
" DeckVariant " , null = True , blank = True ,
on_delete = models . SET_NULL , related_name = " active_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 "
2026-05-01 02:06:55 -04:00
MINOR = " MINOR " # pip cards (numbers 1-10)
MIDDLE = " MIDDLE " # Earthman court cards (M/J/Q/K, numbers 11-14)
2026-03-24 21:07:01 -04:00
ARCANA_CHOICES = [
2026-04-05 22:32:40 -04:00
( MAJOR , " Major Arcana " ) ,
( MINOR , " Minor Arcana " ) ,
( MIDDLE , " Middle Arcana " ) ,
2026-03-24 21:07:01 -04:00
]
WANDS = " WANDS "
CUPS = " CUPS "
SWORDS = " SWORDS "
PENTACLES = " PENTACLES " # Fiorentine 4th suit
2026-04-07 00:22:04 -04:00
CROWNS = " CROWNS " # Earthman 4th suit
BRANDS = " BRANDS " # Earthman Wands
GRAILS = " GRAILS " # Earthman Cups
BLADES = " BLADES " # Earthman Swords
2026-03-24 21:07:01 -04:00
SUIT_CHOICES = [
2026-04-05 22:32:40 -04:00
( WANDS , " Wands " ) ,
( CUPS , " Cups " ) ,
( SWORDS , " Swords " ) ,
2026-03-24 21:07:01 -04:00
( PENTACLES , " Pentacles " ) ,
2026-04-05 22:32:40 -04:00
( CROWNS , " Crowns " ) ,
2026-04-07 00:22:04 -04:00
( BRANDS , " Brands " ) ,
( GRAILS , " Grails " ) ,
( BLADES , " Blades " ) ,
2026-03-24 21:07:01 -04:00
]
deck_variant = models . ForeignKey (
DeckVariant , null = True , blank = True ,
on_delete = models . CASCADE , related_name = " cards " ,
)
name = models . CharField ( max_length = 200 )
2026-04-05 22:32:40 -04:00
arcana = models . CharField ( max_length = 6 , choices = ARCANA_CHOICES )
2026-03-24 21:07:01 -04:00
suit = models . CharField ( max_length = 10 , choices = SUIT_CHOICES , null = True , blank = True )
2026-04-05 22:32:40 -04:00
icon = models . CharField ( max_length = 50 , blank = True , default = ' ' ) # FA icon override (e.g. major arcana)
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
number = models . IntegerField ( ) # 0– 21 major (Fiorentine); 0– 49 major (Earthman); 1– 14 minor
2026-03-24 21:07:01 -04:00
slug = models . SlugField ( max_length = 120 )
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
correspondence = models . CharField ( max_length = 200 , blank = True ) # Tarot / Minchiate equivalent
2026-03-24 21:07:01 -04:00
group = models . CharField ( max_length = 100 , blank = True ) # Earthman major grouping
2026-04-30 21:01:52 -04:00
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
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
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 = ' ' )
2026-04-30 23:36:35 -04:00
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)
2026-04-28 20:22:19 -04:00
energies = models . JSONField ( default = list ) # list of {type, effect} dicts — Energy interactions
operations = models . JSONField ( default = list ) # list of {type, effect} dicts — Operation interactions
2026-03-24 21:07:01 -04:00
keywords_upright = models . JSONField ( default = list )
keywords_reversed = models . JSONField ( default = list )
2026-04-07 00:22:04 -04:00
cautions = models . JSONField ( default = list )
2026-03-24 21:07:01 -04:00
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 ' }
2026-04-29 00:20:55 -04:00
if self . number in court :
return court [ self . number ]
return ' A ' if self . number == 1 else str ( self . number )
2026-03-25 00:24:26 -04:00
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
def emanation_for ( self , polarity ) :
""" Return the upright title for a given polarity ( ' levity ' or ' gravity ' ).
2026-04-30 21:51:23 -04:00
Falls back to name_title ( group prefix stripped ) for cards without a
polarity split . """
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
if polarity == ' levity ' and self . levity_emanation :
return self . levity_emanation
if polarity == ' gravity ' and self . gravity_emanation :
return self . gravity_emanation
2026-04-30 21:51:23 -04:00
return self . name_title
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
def reversal_for ( self , polarity ) :
""" Return the reversed title for a given polarity.
2026-04-30 21:51:23 -04:00
Falls back to reversal_qualifier ( blank = same as emanation_for ) . """
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
if polarity == ' levity ' and self . levity_reversal :
return self . levity_reversal
if polarity == ' gravity ' and self . gravity_reversal :
return self . gravity_reversal
2026-04-30 21:51:23 -04:00
return self . reversal_qualifier or self . emanation_for ( polarity )
Earthman deck: new TarotCard fields + full 49-card major arcana reseed
- TarotCard: add reversal, levity/gravity_qualifier, levity/gravity_emanation, levity/gravity_reversal, mechanisms, articulations; emanation_for(polarity) + reversal_for(polarity) methods
- 0035: schema migration for new fields
- 0036: reseed major arcana — 52 cards → 50 (Nomad untouched); delete Wheel of Fortune/Junkboat/Great Hunt; insert Asteroid Belt; rename/renumber all into Pope/Horseman, Elements, Realms, Virtues, Zodiac, Lunars, Planets, Inner Rings; levity/gravity qualifiers throughout; cards 48-49 polarity-split levity/gravity emanation + reversal
- 0037: Polestar qualifiers → Precessional / Recessional
- 0038: correspondences → Fiorentine card names (Magician–Hierophant for 1-5; sign names for zodiac 22-33)
- 0039: reversals — Pope/Horseman 1-5 → Territoriality/Despotism/Capitalism/Fascism; Zodiac 22-33 → House of Self … House of Reprisal
- 0040: trump 21 → Intent (was Jovent)
⚠ pending: cards 1-2 reversal both 'Territoriality' — confirm if distinct; Implicit/Explicit Virtue qualifiers blank
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 01:51:40 -04:00
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-05-01 02:06:55 -04:00
@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 ' '
2026-03-25 00:24:26 -04:00
@property
def suit_icon ( self ) :
2026-04-05 22:32:40 -04:00
if self . icon :
return self . icon
2026-03-25 00:24:26 -04:00
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-04-05 22:32:40 -04:00
self . CROWNS : ' fa-crown ' ,
2026-04-07 00:22:04 -04:00
self . BRANDS : ' fa-wand-sparkles ' ,
self . GRAILS : ' fa-trophy ' ,
self . BLADES : ' fa-gun ' ,
2026-03-25 00:24:26 -04:00
} . get ( self . suit , ' ' )
2026-04-07 00:22:04 -04:00
@property
def cautions_json ( self ) :
import json
return json . dumps ( self . cautions )
2026-04-28 17:18:16 -04:00
@property
2026-04-28 20:22:19 -04:00
def energies_json ( self ) :
2026-04-28 17:18:16 -04:00
import json
2026-04-28 20:22:19 -04:00
return json . dumps ( self . energies )
2026-04-28 17:18:16 -04:00
@property
2026-04-28 20:22:19 -04:00
def operations_json ( self ) :
2026-04-28 17:18:16 -04:00
import json
2026-04-28 20:22:19 -04:00
return json . dumps ( self . operations )
2026-04-28 17:18:16 -04:00
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
2026-04-05 22:01:23 -04:00
# ── 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 '
)
2026-04-08 22:53:44 -04:00
seat = models . ForeignKey (
' TableSeat ' , null = True , blank = True ,
on_delete = models . SET_NULL , related_name = ' sig_reservation ' ,
)
2026-04-05 22:01:23 -04:00
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 )
2026-04-09 01:17:24 -04:00
ready = models . BooleanField ( default = False )
countdown_remaining = models . IntegerField ( null = True , blank = True )
2026-04-05 22:01:23 -04:00
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 ' ,
) ,
]
2026-03-25 01:50:06 -04:00
# ── Significator deck helpers ─────────────────────────────────────────────────
2026-04-28 00:45:22 -04:00
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
2026-03-25 01:50:06 -04:00
def sig_deck_cards ( room ) :
""" Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
2026-04-07 00:22:04 -04:00
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
2026-03-25 01:50:06 -04:00
Total : 18 unique × 2 ( levity + gravity piles ) = 36 cards .
"""
2026-04-28 00:45:22 -04:00
deck_variant = _room_deck_variant ( room )
2026-03-25 01:50:06 -04:00
if deck_variant is None :
return [ ]
2026-04-05 22:32:40 -04:00
wands_crowns = list ( TarotCard . objects . filter (
2026-03-25 01:50:06 -04:00
deck_variant = deck_variant ,
2026-04-05 22:32:40 -04:00
arcana = TarotCard . MIDDLE ,
2026-04-07 00:22:04 -04:00
suit__in = [ TarotCard . WANDS , TarotCard . BRANDS , TarotCard . CROWNS ] ,
2026-03-25 01:50:06 -04:00
number__in = [ 11 , 12 , 13 , 14 ] ,
) )
swords_cups = list ( TarotCard . objects . filter (
deck_variant = deck_variant ,
2026-04-05 22:32:40 -04:00
arcana = TarotCard . MIDDLE ,
2026-04-07 00:22:04 -04:00
suit__in = [ TarotCard . SWORDS , TarotCard . BLADES , TarotCard . CUPS , TarotCard . GRAILS ] ,
2026-03-25 01:50:06 -04:00
number__in = [ 11 , 12 , 13 , 14 ] ,
) )
major = list ( TarotCard . objects . filter (
deck_variant = deck_variant ,
arcana = TarotCard . MAJOR ,
number__in = [ 0 , 1 ] ,
) )
2026-04-05 22:32:40 -04:00
unique_cards = wands_crowns + swords_cups + major # 18 unique
2026-03-25 01:50:06 -04:00
return unique_cards + unique_cards # × 2 = 36
2026-04-05 22:01:23 -04:00
def _sig_unique_cards ( room ) :
""" Return the 18 unique TarotCard objects that form one sig pile. """
2026-04-28 00:45:22 -04:00
deck_variant = _room_deck_variant ( room )
2026-04-05 22:01:23 -04:00
if deck_variant is None :
return [ ]
2026-04-05 22:32:40 -04:00
wands_crowns = list ( TarotCard . objects . filter (
2026-04-05 22:01:23 -04:00
deck_variant = deck_variant ,
2026-04-05 22:32:40 -04:00
arcana = TarotCard . MIDDLE ,
2026-04-07 00:22:04 -04:00
suit__in = [ TarotCard . WANDS , TarotCard . BRANDS , TarotCard . CROWNS ] ,
2026-04-05 22:01:23 -04:00
number__in = [ 11 , 12 , 13 , 14 ] ,
) )
swords_cups = list ( TarotCard . objects . filter (
deck_variant = deck_variant ,
2026-04-05 22:32:40 -04:00
arcana = TarotCard . MIDDLE ,
2026-04-07 00:22:04 -04:00
suit__in = [ TarotCard . SWORDS , TarotCard . BLADES , TarotCard . CUPS , TarotCard . GRAILS ] ,
2026-04-05 22:01:23 -04:00
number__in = [ 11 , 12 , 13 , 14 ] ,
) )
major = list ( TarotCard . objects . filter (
deck_variant = deck_variant ,
arcana = TarotCard . MAJOR ,
number__in = [ 0 , 1 ] ,
) )
2026-04-05 22:32:40 -04:00
return wands_crowns + swords_cups + major
2026-04-05 22:01:23 -04:00
2026-04-28 01:05:25 -04:00
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
2026-04-28 01:30:02 -04:00
or ( c . number == 0 and earned & { " nomad " , " super-nomad " } )
or ( c . number == 1 and earned & { " schizo " , " super-schizo " } )
2026-04-28 01:05:25 -04:00
]
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 )
2026-04-05 22:01:23 -04:00
2026-04-28 01:05:25 -04:00
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 )
2026-04-05 22:01:23 -04:00
2026-03-25 01:50:06 -04:00
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
PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz
PySwiss:
- calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs)
- /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone)
- aspects included in /api/chart/ response
- timezonefinder==8.2.2 added to requirements
- 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields)
Main app:
- Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033)
- Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross,
confirmed_at/retired_at lifecycle (migration 0034)
- natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution,
computes planet-in-house distinctions, returns enriched JSON
- natus_save view: find-or-create draft Character, confirmed_at on action='confirm'
- natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects,
ASC/MC axes); NatusWheel.draw() / redraw() / clear()
- _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button
with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill,
NVM / SAVE SKY footer; html.natus-open class toggle pattern
- _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown,
portrait collapse at 600px, landscape sidebar z-index sink
- room.html: include overlay when table_status == SKY_SELECT
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
# ── Astrological reference tables (seeded, never user-edited) ─────────────────
class Sign ( models . Model ) :
FIRE = ' Fire '
EARTH = ' Earth '
AIR = ' Air '
WATER = ' Water '
ELEMENT_CHOICES = [ ( e , e ) for e in ( FIRE , EARTH , AIR , WATER ) ]
CARDINAL = ' Cardinal '
FIXED = ' Fixed '
MUTABLE = ' Mutable '
MODALITY_CHOICES = [ ( m , m ) for m in ( CARDINAL , FIXED , MUTABLE ) ]
name = models . CharField ( max_length = 20 , unique = True )
symbol = models . CharField ( max_length = 5 ) # ♈ ♉ … ♓
element = models . CharField ( max_length = 5 , choices = ELEMENT_CHOICES )
modality = models . CharField ( max_length = 8 , choices = MODALITY_CHOICES )
order = models . PositiveSmallIntegerField ( unique = True ) # 0– 11, Aries first
start_degree = models . FloatField ( ) # 0, 30, 60 … 330
class Meta :
ordering = [ ' order ' ]
def __str__ ( self ) :
return self . name
class Planet ( models . Model ) :
name = models . CharField ( max_length = 20 , unique = True )
symbol = models . CharField ( max_length = 5 ) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
order = models . PositiveSmallIntegerField ( unique = True ) # 0– 9, Sun first
class Meta :
ordering = [ ' order ' ]
def __str__ ( self ) :
return self . name
class AspectType ( models . Model ) :
name = models . CharField ( max_length = 20 , unique = True )
symbol = models . CharField ( max_length = 5 ) # ☌ ⚹ □ △ ☍
angle = models . PositiveSmallIntegerField ( ) # 0, 60, 90, 120, 180
orb = models . FloatField ( ) # max allowed orb in degrees
class Meta :
ordering = [ ' angle ' ]
def __str__ ( self ) :
return self . name
class HouseLabel ( models . Model ) :
""" Life-area label for each of the 12 astrological houses (distinctions). """
number = models . PositiveSmallIntegerField ( unique = True ) # 1– 12
name = models . CharField ( max_length = 30 )
keywords = models . CharField ( max_length = 100 , blank = True )
class Meta :
ordering = [ ' number ' ]
def __str__ ( self ) :
return f " { self . number } : { self . name } "
# ── Character ─────────────────────────────────────────────────────────────────
class Character ( models . Model ) :
""" A gamer ' s player-character for one seat in one game session.
Lifecycle :
- Created ( draft ) when gamer opens PICK SKY overlay .
- confirmed_at set on confirm → locked .
- retired_at set on retirement → archived ( seat may hold a new Character ) .
Active character for a seat : confirmed_at__isnull = False , retired_at__isnull = True .
"""
PORPHYRY = ' O '
PLACIDUS = ' P '
KOCH = ' K '
WHOLE = ' W '
HOUSE_SYSTEM_CHOICES = [
( PORPHYRY , ' Porphyry ' ) ,
( PLACIDUS , ' Placidus ' ) ,
( KOCH , ' Koch ' ) ,
( WHOLE , ' Whole Sign ' ) ,
]
# ── seat relationship ─────────────────────────────────────────────────
seat = models . ForeignKey (
TableSeat , on_delete = models . CASCADE , related_name = ' characters ' ,
)
# ── significator (set at PICK SKY) ────────────────────────────────────
significator = models . ForeignKey (
TarotCard , null = True , blank = True ,
on_delete = models . SET_NULL , related_name = ' character_significators ' ,
)
# ── natus input (what the gamer entered) ─────────────────────────────
birth_dt = models . DateTimeField ( null = True , blank = True ) # UTC
birth_lat = models . DecimalField ( max_digits = 9 , decimal_places = 6 , null = True , blank = True )
birth_lon = models . DecimalField ( max_digits = 9 , decimal_places = 6 , null = True , blank = True )
birth_place = models . CharField ( max_length = 200 , blank = True ) # display string only
house_system = models . CharField (
max_length = 1 , choices = HOUSE_SYSTEM_CHOICES , default = PORPHYRY ,
)
# ── computed natus snapshot (full PySwiss response) ───────────────────
chart_data = models . JSONField ( null = True , blank = True )
# ── celtic cross spread (added at PICK SEA) ───────────────────────────
celtic_cross = models . JSONField ( null = True , blank = True )
# ── lifecycle ─────────────────────────────────────────────────────────
created_at = models . DateTimeField ( auto_now_add = True )
confirmed_at = models . DateTimeField ( null = True , blank = True )
retired_at = models . DateTimeField ( null = True , blank = True )
class Meta :
ordering = [ ' -created_at ' ]
def __str__ ( self ) :
status = ' confirmed ' if self . confirmed_at else ' draft '
return f " Character(seat= { self . seat_id } , { status } ) "
@property
def is_confirmed ( self ) :
return self . confirmed_at is not None
@property
def is_active ( self ) :
return self . confirmed_at is not None and self . retired_at is None