Compare commits

..

3 Commits

Author SHA1 Message Date
Disco DeDisco
c3f0342a2d Earthman deck: new TarotCard fields + full 49-card major arcana reseed
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- 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
Disco DeDisco
ad7a354f8c PICK SEA overlay: sea SCSS → _card-deck.scss; Sig card + qualifier display; crossing slot deferred
- sea overlay SCSS moved from _natus.scss to end of _card-deck.scss (correct file for card/overlay primitives)
- Significator center slot: sig-stage-card w. .sea-cross context rule (background, border, aspect-ratio, overflow); fan-card-face name + sig-qualifier-above/below at 0.5rem w. word-wrap
- _sea_overlay.html: qualifier rendered from user_polarity + arcana (MIDDLE → Leavened/Graven above; MAJOR → below); crossing slot removed from HTML + grid-template-areas (deferred re-add later)
- SCSS grid trimmed to 3 rows (crown/past-center-future/root); .sea-pos-crossing class kept for later reuse

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:02:01 -04:00
Disco DeDisco
7fcb6f307c PICK SEA: modal w. Celtic Cross layout + spread select; PICK SKY swaps to PICK SEA after sky save — TDD
- _role_select_context: at SKY_SELECT, compute sky_confirmed (confirmed Character exists for seat) + user_polarity
- room.html: PICK SEA btn + _sea_overlay.html when sky_confirmed; PICK SKY + natus overlay otherwise
- _sea_overlay.html: transparent cards col (6-position cross, Sig at center) left; priUser form col (spread select) right; NVM cancel; JS open/close via html.sea-open
- _natus.scss: .sea-* rules mirror natus layout w. reversed columns; crossing slot rotated; dotted empty slots; sig slot solid; width/max-width replaces min() to avoid rem+vw unit mix
- select defaults: "Celtic Cross, Waite-Smith" for levity (PC/NC/SC); "Celtic Cross, Escape Velocity" for gravity (EC/AC/BC)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 21:30:27 -04:00
12 changed files with 969 additions and 6 deletions

View File

@@ -0,0 +1,58 @@
# Generated by Django 6.0 on 2026-04-27 05:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0034_character_model'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='articulations',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='tarotcard',
name='gravity_emanation',
field=models.CharField(blank=True, default='', max_length=200),
),
migrations.AddField(
model_name='tarotcard',
name='gravity_qualifier',
field=models.CharField(blank=True, default='', max_length=100),
),
migrations.AddField(
model_name='tarotcard',
name='gravity_reversal',
field=models.CharField(blank=True, default='', max_length=200),
),
migrations.AddField(
model_name='tarotcard',
name='levity_emanation',
field=models.CharField(blank=True, default='', max_length=200),
),
migrations.AddField(
model_name='tarotcard',
name='levity_qualifier',
field=models.CharField(blank=True, default='', max_length=100),
),
migrations.AddField(
model_name='tarotcard',
name='levity_reversal',
field=models.CharField(blank=True, default='', max_length=200),
),
migrations.AddField(
model_name='tarotcard',
name='mechanisms',
field=models.JSONField(default=list),
),
migrations.AddField(
model_name='tarotcard',
name='reversal',
field=models.CharField(blank=True, default='', max_length=200),
),
]

View File

@@ -0,0 +1,307 @@
"""
Re-seed the Earthman major arcana from 52 cards (0-51) to 50 cards (0 + 1-49).
Card 0 (The Nomad) is left completely untouched.
Changes:
DELETE old #10 Wheel of Fortune, #11 The Junkboat, #14 The Great Hunt
INSERT new #41 The Asteroid Belt
UPDATE all other major arcana with new numbers, names, slugs, groups,
correspondences, reversals, and levity/gravity qualifiers.
Items flagged ⚠ below were not clearly legible from the source image and are
left blank; the user should fill them in a follow-up migration.
"""
from django.db import migrations
# ---------------------------------------------------------------------------
# UPDATE spec: (current_slug, new_number, new_name, new_slug, new_group,
# correspondence, reversal,
# levity_qualifier, gravity_qualifier,
# levity_emanation, gravity_emanation,
# levity_reversal, gravity_reversal)
# ---------------------------------------------------------------------------
_UPDATES = [
# ── Pope/Horseman ──────────────────────────────────────────────────────
('pope-1-the-schizo', 1, 'The Schizo',
'the-schizo', 'Pope/Horseman',
'Territoriality', '',
'Enlightened', 'Engraven',
'', '', '', ''),
('pope-2-the-occultist', 2, 'The Occultist',
'the-occultist', 'Pope/Horseman',
'Territoriality', '',
'Enlightened', 'Engraven',
'', '', '', ''),
('pope-3-the-despot', 3, 'The Despot',
'the-despot', 'Pope/Horseman',
'Despotism', '',
'Enlightened', 'Engraven',
'', '', '', ''),
('pope-4-the-capitalist', 4, 'The Capitalist',
'the-capitalist', 'Pope/Horseman',
'Capitalism', '',
'Enlightened', 'Engraven',
'', '', '', ''),
('pope-5-the-fascist', 5, 'The Fascist',
'the-fascist', 'Pope/Horseman',
'Fascism', '',
'Enlightened', 'Engraven',
'', '', '', ''),
# ── Implicit Virtues ───────────────────────────────────────────────────
# ⚠ levity/gravity qualifiers not determined
('implicit-virtue-1-controlled-folly', 6, 'Controlled Folly',
'controlled-folly', 'Implicit Virtues',
'Fortitude', '',
'', '', '', '', '', ''),
('implicit-virtue-2-not-doing', 7, 'Not Doing',
'not-doing', 'Implicit Virtues',
'Temperance', '',
'', '', '', '', '', ''),
('implicit-virtue-3-losing-self-importance', 8, 'Losing Self-Importance',
'losing-self-importance', 'Implicit Virtues',
'Justice', '',
'', '', '', '', '', ''),
('implicit-virtue-4-erasing-personal-history', 9, 'Erasing Personal History',
'erasing-personal-history', 'Implicit Virtues',
'Prudence', '',
'', '', '', '', '', ''),
# ── Elements (6) — Absolute elements move here alongside Classical ─────
# Space: was Absolute Element 2 (#38), Chariot correspondence is new
('absolute-element-2-space', 10, 'Space',
'space-em', 'Elements',
'Chariot', 'Nexus',
'Kinetic', 'Potential',
'', '', '', ''),
# Time: was Absolute Element 1 (#37)
# ⚠ Hermit correspondence confirmed; "Hunchback" variant noted in image
('absolute-element-1-time', 11, 'Time',
'time-em', 'Elements',
'Hermit', 'Tempo',
'Kinetic', 'Potential',
'', '', '', ''),
# Stone: was Classical Element 2 (#22)
('classical-element-2-stone', 12, 'Stone',
'stone-em', 'Elements',
'Earth', 'Ossum',
'Kinetic', 'Potential',
'', '', '', ''),
# Fire: was Classical Element 1 (#21)
('classical-element-1-fire', 13, 'Fire',
'fire-em', 'Elements',
'Fire', 'Ardor',
'Kinetic', 'Potential',
'', '', '', ''),
# Water: was Classical Element 4 (#24)
('classical-element-4-water', 14, 'Water',
'water-em', 'Elements',
'Water', 'Humor',
'Kinetic', 'Potential',
'', '', '', ''),
# Air: was Classical Element 3 (#23)
('classical-element-3-air', 15, 'Air',
'air-em', 'Elements',
'Air', 'Pneuma',
'Kinetic', 'Potential',
'', '', '', ''),
# ── Realms ─────────────────────────────────────────────────────────────
('disco-inferno', 16, 'Disco Inferno',
'disco-inferno', 'Realms',
'Devil', 'Shame',
'Deasil', 'Widdershins',
'', '', '', ''),
('torre-terrestre', 17, 'Torre Terrestre',
'torre-terrestre', 'Realms',
'Tower', 'Guilt',
'Deasil', 'Widdershins',
'', '', '', ''),
('fantasia-celestia', 18, 'Fantasia Celestia',
'fantasia-celestia', 'Realms',
'Wheel of Fortune', 'Anxiety',
'Deasil', 'Widdershins',
'', '', '', ''),
# ── Explicit Virtues ───────────────────────────────────────────────────
# ⚠ levity/gravity qualifiers not determined
# Stalking: was Explicit Virtue 1 (#18, Love/Charity)
('explicit-virtue-1-stalking', 19, 'Stalking',
'stalking', 'Explicit Virtues',
'Charity', '',
'', '', '', '', '', ''),
# Dreaming: was Explicit Virtue 3 (#20, Faith) — same number
('explicit-virtue-3-dreaming', 20, 'Dreaming',
'dreaming', 'Explicit Virtues',
'Faith', '',
'', '', '', '', '', ''),
# Jovent: was Explicit Virtue 2 (#19, Intent/Hope) — renamed
('explicit-virtue-2-intent', 21, 'Jovent',
'jovent', 'Explicit Virtues',
'Hope', '',
'', '', '', '', '', ''),
# ── Zodiac Signs & Houses (renumber 25-36 → 22-33) ────────────────────
('zodiac-1-aries', 22, 'Aries', 'aries', 'Zodiac Signs & Houses', 'House of Self', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-2-taurus', 23, 'Taurus', 'taurus', 'Zodiac Signs & Houses', 'House of Worth', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-3-gemini', 24, 'Gemini', 'gemini', 'Zodiac Signs & Houses', 'House of Education', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-4-cancer', 25, 'Cancer', 'cancer', 'Zodiac Signs & Houses', 'House of Family', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-5-leo', 26, 'Leo', 'leo', 'Zodiac Signs & Houses', 'House of Creation', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-6-virgo', 27, 'Virgo', 'virgo', 'Zodiac Signs & Houses', 'House of Ritual', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-7-libra', 28, 'Libra', 'libra', 'Zodiac Signs & Houses', 'House of Cooperation', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-8-scorpio', 29, 'Scorpio', 'scorpio', 'Zodiac Signs & Houses', 'House of Regeneration', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-9-sagittarius', 30, 'Sagittarius', 'sagittarius', 'Zodiac Signs & Houses', 'House of Enterprise', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-10-capricorn', 31, 'Capricorn', 'capricorn', 'Zodiac Signs & Houses', 'House of Career', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-11-aquarius', 32, 'Aquarius', 'aquarius', 'Zodiac Signs & Houses', 'House of Reward', '', 'Precessional', 'Recessional', '', '', '', ''),
('zodiac-12-pisces', 33, 'Pisces', 'pisces', 'Zodiac Signs & Houses', 'House of Reprisal', '', 'Precessional', 'Recessional', '', '', '', ''),
# ── Lunars ─────────────────────────────────────────────────────────────
# Animal Powers: was The Junkman (#12, Hanged Man) — renamed
('the-junkman', 34, 'Animal Powers',
'animal-powers', 'Lunars',
'Hanged Man', 'Patrilineage',
'Centrifugal', 'Centripetal',
'', '', '', ''),
# Seeded Earth: was King Death & the Cosmic Tree (#13, Death) — renamed
('king-death-and-the-cosmic-tree', 35, 'Seeded Earth',
'seeded-earth', 'Lunars',
'Death', 'Matrilineage',
'Centrifugal', 'Centripetal',
'', '', '', ''),
# Twins of Pluto: was Wanderer 11 King & Queen of Hades (#49) — renamed
('wanderer-11-king-queen-hades', 36, 'The Twins of Pluto',
'twins-of-pluto', 'Lunars',
'Pluto', '',
'Prograde', 'Retrograde',
'', '', '', ''),
# ── Planets ────────────────────────────────────────────────────────────
# ⚠ correspondences left blank — image unclear; user to confirm
# Reversals: user confirmed all planet cards reverse to same (blank = same)
('wanderer-10-neptune', 37, 'Neptune', 'neptune', 'Planets', 'Neptune', '', 'Prograde', 'Retrograde', '', '', '', ''),
('wanderer-9-uranus', 38, 'Uranus', 'uranus', 'Planets', 'Uranus', '', 'Prograde', 'Retrograde', '', '', '', ''),
('wanderer-8-saturn', 39, 'Saturn', 'saturn', 'Planets', 'Saturn', '', 'Prograde', 'Retrograde', '', '', '', ''),
('wanderer-7-jupiter', 40, 'Jupiter', 'jupiter', 'Planets', 'Jupiter', '', 'Prograde', 'Retrograde', '', '', '', ''),
# #41 The Asteroid Belt — INSERT separately below
('wanderer-6-mars', 42, 'Mars', 'mars', 'Planets', 'Mars', '', 'Prograde', 'Retrograde', '', '', '', ''),
('wanderer-5-venus', 43, 'Venus', 'venus', 'Planets', 'Venus', '', 'Prograde', 'Retrograde', '', '', '', ''),
('wanderer-4-mercury', 44, 'Mercury', 'mercury', 'Planets', 'Mercury', '', 'Prograde', 'Retrograde', '', '', '', ''),
# ── Inner Rings ────────────────────────────────────────────────────────
# ⚠ Polestar levity/gravity qualifiers not confirmed from image
('wanderer-1-polestar', 45, 'The Polestar', 'the-polestar', 'Inner Rings', 'Star', '', '', '', '', '', '', ''),
('wanderer-2-antichthon',46, 'The Antichthon', 'the-antichthon', 'Inner Rings', 'Moon', '', 'Waxing', 'Waning', '', '', '', ''),
# Corestar: user changed Ascendant→Inclining, Descendent→Declining
('wanderer-3-corestar', 47, 'The Corestar', 'the-corestar', 'Inner Rings', 'Sun', '', 'Inclining', 'Declining', '', '', '', ''),
# ── Polarity-split finals ──────────────────────────────────────────────
# 48: The Eagle → Father Sky (levity) / Mother Sea (gravity)
('the-eagle', 48, 'Father Sky / Mother Sea',
'father-sky-mother-sea', '',
'World', '',
'', '',
'Father Sky', 'Mother Sea',
'The Storm', 'The Flood'),
# 49: The Mould of Man → Effulgent Mould of Man (levity) / Devouring Eagle (gravity)
('the-mould-of-man', 49, 'The Effulgent Mould of Man / The Devouring Eagle',
'effulgent-mould-devouring-eagle', '',
'Trumpets', '',
'', '',
'The Effulgent Mould of Man', 'The Devouring Eagle',
'', ''),
]
_DELETE_SLUGS = [
'wheel-of-fortune-em', # old #10 — correspondence absorbed by Fantasia Celestia
'the-junkboat', # old #11 — Space element takes the Chariot correspondence
'the-great-hunt', # old #14 — Disco Inferno takes the Devil correspondence
]
def reseed_earthman_majors(apps, schema_editor):
TarotCard = apps.get_model('epic', 'TarotCard')
DeckVariant = apps.get_model('epic', 'DeckVariant')
try:
earthman = DeckVariant.objects.get(slug='earthman')
except DeckVariant.DoesNotExist:
return # no-op in fresh DB without seeded variants
# 1. Delete retired cards
TarotCard.objects.filter(deck_variant=earthman, slug__in=_DELETE_SLUGS).delete()
# 2. Update existing cards — set new number to a temporary negative to
# avoid any incidental ordering conflicts mid-loop, then finalise.
# (No unique constraint on number, so straightforward.)
for row in _UPDATES:
(cur_slug, new_num, new_name, new_slug, new_group,
corr, reversal, lq, gq, le, ge, lr, gr) = row
TarotCard.objects.filter(
deck_variant=earthman, slug=cur_slug,
).update(
number=new_num,
name=new_name,
slug=new_slug,
group=new_group,
correspondence=corr,
reversal=reversal,
levity_qualifier=lq,
gravity_qualifier=gq,
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
)
# 3. Insert The Asteroid Belt (new card, no existing equivalent)
TarotCard.objects.get_or_create(
deck_variant=earthman,
slug='the-asteroid-belt',
defaults=dict(
number=41,
name='The Asteroid Belt',
arcana='MAJOR',
group='Planets',
correspondence='',
reversal='Ouroboros',
levity_qualifier='Prograde',
gravity_qualifier='Retrograde',
),
)
def reverse_reseed(apps, schema_editor):
pass # irreversible structural change
class Migration(migrations.Migration):
dependencies = [
('epic', '0035_earthman_deck_new_fields'),
]
operations = [
migrations.RunPython(reseed_earthman_majors, reverse_reseed),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def set_polestar_qualifiers(apps, schema_editor):
TarotCard = apps.get_model('epic', 'TarotCard')
DeckVariant = apps.get_model('epic', 'DeckVariant')
try:
earthman = DeckVariant.objects.get(slug='earthman')
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, slug='the-polestar',
).update(levity_qualifier='Precessional', gravity_qualifier='Recessional')
class Migration(migrations.Migration):
dependencies = [
('epic', '0036_earthman_deck_reseed'),
]
operations = [
migrations.RunPython(set_polestar_qualifiers, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,60 @@
"""
Fix TarotCard.correspondence values for Earthman major arcana to store
the Fiorentine/Tarot card name (1:1 correspondence) rather than Earthman
conceptual terms.
Two batches corrected:
1. Pope/Horseman (1-5): Territoriality/Despotism/Capitalism/Fascism
→ Magician/High Priestess/Empress/Emperor/Hierophant
2. Zodiac (22-33): House of Self/Worth/… → Aries/Taurus/…/Pisces
(Minchiate Fiorentine has all 12 zodiac sign cards; correspondence = sign name)
"""
from django.db import migrations
_POPE_FIXES = {
'the-schizo': 'Magician',
'the-occultist': 'High Priestess',
'the-despot': 'Empress',
'the-capitalist': 'Emperor',
'the-fascist': 'Hierophant',
}
_ZODIAC_FIXES = {
'aries': 'Aries',
'taurus': 'Taurus',
'gemini': 'Gemini',
'cancer': 'Cancer',
'leo': 'Leo',
'virgo': 'Virgo',
'libra': 'Libra',
'scorpio': 'Scorpio',
'sagittarius': 'Sagittarius',
'capricorn': 'Capricorn',
'aquarius': 'Aquarius',
'pisces': 'Pisces',
}
def fix_correspondences(apps, schema_editor):
TarotCard = apps.get_model('epic', 'TarotCard')
DeckVariant = apps.get_model('epic', 'DeckVariant')
try:
earthman = DeckVariant.objects.get(slug='earthman')
except DeckVariant.DoesNotExist:
return
for slug, corr in {**_POPE_FIXES, **_ZODIAC_FIXES}.items():
TarotCard.objects.filter(deck_variant=earthman, slug=slug).update(
correspondence=corr,
)
class Migration(migrations.Migration):
dependencies = [
('epic', '0037_polestar_qualifiers'),
]
operations = [
migrations.RunPython(fix_correspondences, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,59 @@
"""
Fix TarotCard.reversal values for Pope/Horseman and Zodiac cards.
Pope/Horseman (1-5): reversed-state names read from image reversal column.
⚠ Cards 1-2 (Schizo/Occultist): image showed 'Territoriality*' spanning
rows 1-2 — stored as 'Territoriality' for both; user to confirm if
they have distinct reversed names.
Zodiac (22-33): reversed state = corresponding house (e.g. Aries → House of Self).
"""
from django.db import migrations
_POPE_REVERSALS = {
'the-schizo': 'Territoriality', # ⚠ confirm if distinct from Occultist
'the-occultist': 'Territoriality', # ⚠ confirm
'the-despot': 'Despotism',
'the-capitalist': 'Capitalism',
'the-fascist': 'Fascism',
}
_ZODIAC_REVERSALS = {
'aries': 'House of Self',
'taurus': 'House of Worth',
'gemini': 'House of Education',
'cancer': 'House of Family',
'leo': 'House of Creation',
'virgo': 'House of Ritual',
'libra': 'House of Cooperation',
'scorpio': 'House of Regeneration',
'sagittarius': 'House of Enterprise',
'capricorn': 'House of Career',
'aquarius': 'House of Reward',
'pisces': 'House of Reprisal',
}
def fix_reversals(apps, schema_editor):
TarotCard = apps.get_model('epic', 'TarotCard')
DeckVariant = apps.get_model('epic', 'DeckVariant')
try:
earthman = DeckVariant.objects.get(slug='earthman')
except DeckVariant.DoesNotExist:
return
for slug, reversal in {**_POPE_REVERSALS, **_ZODIAC_REVERSALS}.items():
TarotCard.objects.filter(
deck_variant=earthman, slug=slug,
).update(reversal=reversal)
class Migration(migrations.Migration):
dependencies = [
('epic', '0038_fix_correspondences'),
]
operations = [
migrations.RunPython(fix_reversals, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def rename_jovent_to_intent(apps, schema_editor):
TarotCard = apps.get_model('epic', 'TarotCard')
DeckVariant = apps.get_model('epic', 'DeckVariant')
try:
earthman = DeckVariant.objects.get(slug='earthman')
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, slug='jovent',
).update(name='Intent', slug='intent')
class Migration(migrations.Migration):
dependencies = [
('epic', '0039_fix_reversals'),
]
operations = [
migrations.RunPython(rename_jovent_to_intent, migrations.RunPython.noop),
]

View File

@@ -242,10 +242,19 @@ class TarotCard(models.Model):
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); 051 major (Earthman); 114 minor
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) # standard / Italian equivalent
correspondence = models.CharField(max_length=200, blank=True) # Tarot / Minchiate equivalent
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
reversal = models.CharField(max_length=200, blank=True, default='') # reversed-state title; blank = same as name
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='')
mechanisms = models.JSONField(default=list) # list of dicts; in-game effects
articulations = models.JSONField(default=list) # list of dicts; combinatory effects
keywords_upright = models.JSONField(default=list)
keywords_reversed = models.JSONField(default=list)
cautions = models.JSONField(default=list)
@@ -274,6 +283,24 @@ class TarotCard(models.Model):
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
return court.get(self.number, str(self.number))
def emanation_for(self, polarity):
"""Return the upright title for a given polarity ('levity' or 'gravity').
Falls back to name 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
def reversal_for(self, polarity):
"""Return the reversed title for a given polarity.
Falls back to reversal (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 or self.emanation_for(polarity)
@property
def name_group(self):
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""

View File

@@ -8,7 +8,7 @@ from django.utils import timezone
from apps.drama.models import GameEvent
from apps.lyric.models import Token, User
from apps.epic.models import (
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
)
@@ -1652,6 +1652,69 @@ class PickSkyRenderingTest(TestCase):
self.assertContains(response, 'style="display:none"')
# ── SEA_SELECT rendering ──────────────────────────────────────────────────────
class PickSeaRenderingTest(TestCase):
"""At SKY_SELECT, a confirmed Character swaps PICK SKY → PICK SEA + sea overlay."""
def setUp(self):
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
self.room.table_status = Room.SKY_SELECT
self.room.save()
self.sig_card = TarotCard.objects.get(
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
)
self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
self.pc_seat.significator = self.sig_card
self.pc_seat.save()
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
def _confirm_sky(self, seat=None):
target = seat or self.pc_seat
return Character.objects.create(seat=target, confirmed_at=timezone.now())
def test_sky_confirmed_false_when_no_character(self):
response = self.client.get(self.url)
self.assertFalse(response.context["sky_confirmed"])
def test_sky_confirmed_true_when_character_confirmed(self):
self._confirm_sky()
response = self.client.get(self.url)
self.assertTrue(response.context["sky_confirmed"])
def test_pick_sea_btn_shown_when_sky_confirmed(self):
self._confirm_sky()
response = self.client.get(self.url)
self.assertContains(response, "id_pick_sea_btn")
def test_pick_sky_btn_shown_when_sky_not_confirmed(self):
response = self.client.get(self.url)
self.assertContains(response, "id_pick_sky_btn")
def test_sea_overlay_included_when_sky_confirmed(self):
self._confirm_sky()
response = self.client.get(self.url)
self.assertContains(response, "id_sea_overlay")
def test_sea_overlay_select_defaults_to_waite_smith_for_levity(self):
self._confirm_sky()
response = self.client.get(self.url)
self.assertContains(response, "Celtic Cross, Waite-Smith")
def test_sea_overlay_select_defaults_to_escape_velocity_for_gravity(self):
ec_gamer = self.gamers[2] # EC — gravity
self.client.force_login(ec_gamer)
ec_seat = TableSeat.objects.get(room=self.room, role="EC")
self._confirm_sky(seat=ec_seat)
response = self.client.get(self.url)
self.assertContains(response, "Celtic Cross, Escape Velocity")
def test_user_polarity_in_context_at_sky_select(self):
response = self.client.get(self.url)
self.assertIn("user_polarity", response.context)
self.assertEqual(response.context["user_polarity"], "levity") # PC is levity
# ── select_role GET redirect ──────────────────────────────────────────────────
class SelectRoleGetRedirectTest(TestCase):

View File

@@ -342,6 +342,24 @@ def _role_select_context(room, user):
ctx["sig_cards"] = gravity_sig_cards(room)
else:
ctx["sig_cards"] = []
if room.table_status == Room.SKY_SELECT:
user_role = _canonical_seat.role if _canonical_seat else None
user_polarity = None
if user_role in _LEVITY_ROLES:
user_polarity = 'levity'
elif user_role in _GRAVITY_ROLES:
user_polarity = 'gravity'
ctx["user_polarity"] = user_polarity
sky_confirmed = bool(
_canonical_seat and Character.objects.filter(
seat=_canonical_seat,
confirmed_at__isnull=False,
retired_at__isnull=True,
).exists()
)
ctx["sky_confirmed"] = sky_confirmed
return ctx

View File

@@ -670,3 +670,204 @@ html:has(.sig-backdrop) {
#id_room_menu { right: 2.5rem; }
}
// ── PICK SEA overlay ─────────────────────────────────────────────────────────
// Mirrors .natus-* structure but with columns reversed:
// left = transparent (Celtic Cross card positions)
// right = rgba(--priUser) opaque (spread select)
.sea-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
z-index: 200;
}
html.sea-open .sea-backdrop { display: block; }
.sea-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 201;
overflow-y: auto;
align-items: center;
justify-content: center;
}
html.sea-open .sea-overlay { display: flex; }
.sea-modal-wrap {
position: relative;
width: 90vw;
max-width: 60rem;
max-height: 90vh;
margin: auto;
opacity: 0;
transform: translateY(1.5rem);
transition: opacity 0.25s, transform 0.25s;
}
html.sea-open .sea-modal-wrap {
opacity: 1;
transform: translateY(0);
}
.sea-modal {
border-radius: 0.5rem;
overflow: hidden;
width: 100%;
}
.sea-modal-header {
padding: 0.75rem 1.25rem;
background: rgba(var(--priUser), 1);
h2 { font-size: 1.4rem; margin: 0; }
p { margin: 0.2rem 0 0; font-size: 0.85rem; opacity: 0.8; }
}
.sea-modal-body {
display: flex;
min-height: 20rem;
}
// ── Cards column (transparent / left) ────────────────────────────────────────
.sea-cards-col {
flex: 1 1 55%;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.sea-cross {
display: grid;
grid-template-areas:
". crown . "
"past center future "
". root . ";
grid-template-columns: 1fr 1fr 1fr;
grid-template-rows: auto auto auto;
gap: 0.5rem;
align-items: center;
justify-items: center;
}
.sea-cross-cell { display: flex; align-items: center; justify-content: center; }
.sea-pos-crown { grid-area: crown; }
.sea-pos-past { grid-area: past; }
.sea-pos-center { grid-area: center; }
.sea-pos-future { grid-area: future; }
.sea-pos-root { grid-area: root; }
.sea-pos-crossing { grid-area: crossing; }
$sea-card-w: 4rem;
$sea-card-h: 6.5rem;
.sea-card-slot {
width: $sea-card-w;
height: $sea-card-h;
border: 0.15rem dashed rgba(var(--terUser), 0.45);
border-radius: 0.3rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6rem;
color: rgba(var(--terUser), 0.6);
}
.sea-card-slot--crossing {
width: $sea-card-h; // rotated — swap w/h
height: $sea-card-w;
}
// .sig-stage-card is normally scoped inside .sig-stage — re-apply the card shell
// here so it renders correctly outside that context.
.sea-cross .sig-stage-card {
flex-shrink: 0;
width: var(--sig-card-w, #{$sea-card-w});
height: auto;
aspect-ratio: 5 / 8;
border-radius: 0.5rem;
background: rgba(var(--priUser), 1);
border: 0.15rem solid rgba(var(--secUser), 0.6);
display: flex;
flex-direction: column;
position: relative;
padding: 0.25rem;
overflow: hidden;
.fan-card-face {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 0.25rem 0.15rem;
.fan-card-name,
.sig-qualifier-above,
.sig-qualifier-below { font-size: 0.5rem; font-weight: 600; white-space: normal; word-break: break-word; line-height: 1.3; margin: 0; }
}
}
// ── Form column (priUser / opaque / right) ────────────────────────────────────
.sea-form-col {
flex: 0 0 auto;
width: 16rem;
display: flex;
flex-direction: column;
padding: 1.25rem;
background: rgba(var(--priUser), 1);
}
.sea-form-main {
flex: 1;
overflow-y: auto;
}
.sea-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
}
.sea-select {
background: rgba(var(--duoUser), 0.6);
border: 1px solid rgba(var(--terUser), 0.3);
border-radius: 0.3rem;
color: inherit;
padding: 0.4rem 0.5rem;
font-size: 0.85rem;
width: 100%;
option { background: rgba(var(--priUser), 1); }
}
.sea-form-col > #id_sea_deal {
margin-top: auto;
}
// NVM button — same positioning as .natus-modal-wrap > .btn-cancel
.sea-modal-wrap > .btn-cancel {
position: absolute;
top: -0.75rem;
right: -0.75rem;
z-index: 10;
}
@media (orientation: landscape) {
html.sea-open body .container .navbar,
html.sea-open body #id_footer {
z-index: 90;
}
}

View File

@@ -0,0 +1,113 @@
{% load static %}
{# PICK SEA overlay — Celtic Cross spread entry #}
{# Included in room.html when table_status == "SKY_SELECT" and sky_confirmed #}
{# Layout is the reverse of PICK SKY: cards left (transparent), form right #}
<div class="sea-backdrop"></div>
<div class="sea-overlay" id="id_sea_overlay">
<div class="sea-modal-wrap">
<div class="sea-modal">
<header class="sea-modal-header">
<h2>PICK <span>SEA</span></h2>
<p>Draw cards to circumscribe your character's influences and seed the Voronoi map.</p>
</header>
<div class="sea-modal-body">
{# ── Cards column (transparent) ───────────────────────────── #}
<div class="sea-cards-col">
<div class="sea-cross">
{# Crown — position 3 #}
<div class="sea-cross-cell sea-pos-crown">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Past — position 4 #}
<div class="sea-cross-cell sea-pos-past">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Center — Significator (already placed) #}
<div class="sea-cross-cell sea-pos-center">
<div class="sig-stage-card" style="--sig-card-w: 4rem">
{% if my_tray_sig %}
<div class="fan-card-face">
{% if my_tray_sig.arcana == "MIDDLE" %}
<p class="sig-qualifier-above">{% if user_polarity == "levity" %}Leavened{% else %}Graven{% endif %}</p>
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
{% elif my_tray_sig.arcana == "MAJOR" %}
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
<p class="sig-qualifier-below">{% if user_polarity == "levity" %}Leavened{% else %}Graven{% endif %}</p>
{% else %}
<p class="fan-card-name">{{ my_tray_sig.name_title }}</p>
{% endif %}
</div>
{% endif %}
</div>
</div>
{# Future — position 5 #}
<div class="sea-cross-cell sea-pos-future">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Root — position 1 #}
<div class="sea-cross-cell sea-pos-root">
<div class="sea-card-slot sea-card-slot--empty"></div>
</div>
{# Crossing — position 2 (rotated) deferred; re-add once layout is finalized #}
</div>
</div>
{# ── Form column (priUser / opaque) ───────────────────────── #}
<div class="sea-form-col">
<div class="sea-form-main">
<div class="sea-field">
<label for="id_sea_spread">Spread</label>
<select id="id_sea_spread" name="spread" class="sea-select">
{% if user_polarity == "levity" %}
<option value="waite-smith" selected>Celtic Cross, Waite-Smith</option>
<option value="escape-velocity">Celtic Cross, Escape Velocity</option>
{% else %}
<option value="escape-velocity" selected>Celtic Cross, Escape Velocity</option>
<option value="waite-smith">Celtic Cross, Waite-Smith</option>
{% endif %}
</select>
</div>
</div>
<button type="button" id="id_sea_deal" class="btn btn-primary" disabled>
Deal
</button>
</div>
</div>{# /.sea-modal-body #}
</div>{# /.sea-modal #}
<button type="button" id="id_sea_cancel" class="btn btn-cancel btn-sm">NVM</button>
</div>{# /.sea-modal-wrap #}
</div>{# /.sea-overlay #}
<script>
(function () {
'use strict';
const overlay = document.getElementById('id_sea_overlay');
const cancelBtn = document.getElementById('id_sea_cancel');
function openSea() {
document.documentElement.classList.add('sea-open');
}
function closeSea() {
document.documentElement.classList.remove('sea-open');
}
const pickSeaBtn = document.getElementById('id_pick_sea_btn');
if (pickSeaBtn) pickSeaBtn.addEventListener('click', openSea);
cancelBtn.addEventListener('click', closeSea);
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSea(); });
})();
</script>

View File

@@ -35,7 +35,11 @@
{% endif %}
{% endif %}
{% if room.table_status == "SKY_SELECT" %}
<button id="id_pick_sky_btn" class="btn btn-primary">PICK<br>SKY</button>
{% if sky_confirmed %}
<button id="id_pick_sea_btn" class="btn btn-primary">PICK<br>SEA</button>
{% else %}
<button id="id_pick_sky_btn" class="btn btn-primary">PICK<br>SKY</button>
{% endif %}
{% elif room.table_status == "SIG_SELECT" %}
<button id="id_pick_sky_btn" class="btn btn-primary" style="display:none">PICK<br>SKY</button>
{% if polarity_done %}
@@ -68,15 +72,20 @@
{% endif %}
{# Natus (Pick Sky) overlay — natal chart entry #}
{% if room.table_status == "SKY_SELECT" %}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed %}
{% include "apps/gameboard/_partials/_natus_overlay.html" %}
{% endif %}
{# Natus tooltip: sibling of .natus-overlay, not inside .natus-modal-wrap (which has transform) #}
{% if room.table_status == "SKY_SELECT" %}
{% if room.table_status == "SKY_SELECT" and not sky_confirmed %}
<div id="id_natus_tooltip" class="tt" style="display:none;"></div>
<div id="id_natus_tooltip_2" class="tt" style="display:none;"></div>
{% endif %}
{# Sea (Pick Sea) overlay — Celtic Cross spread entry #}
{% if room.table_status == "SKY_SELECT" and sky_confirmed %}
{% include "apps/gameboard/_partials/_sea_overlay.html" %}
{% endif %}
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
{% include "apps/gameboard/_partials/_table_positions.html" %}
{% endif %}