diff --git a/src/apps/epic/admin.py b/src/apps/epic/admin.py index 8c38f3f..8b7f738 100644 --- a/src/apps/epic/admin.py +++ b/src/apps/epic/admin.py @@ -1,3 +1,18 @@ from django.contrib import admin -# Register your models here. +from .models import DeckVariant, TarotCard + + +@admin.register(DeckVariant) +class DeckVariantAdmin(admin.ModelAdmin): + list_display = ["name", "slug", "card_count", "is_default"] + prepopulated_fields = {"slug": ["name"]} + + +@admin.register(TarotCard) +class TarotCardAdmin(admin.ModelAdmin): + list_display = ["name", "deck_variant", "arcana", "suit", "number", "group", "slug"] + list_filter = ["deck_variant", "arcana", "suit"] + search_fields = ["name", "slug", "correspondence", "group"] + readonly_fields = ["slug", "correspondence", "group"] + ordering = ["deck_variant", "arcana", "suit", "number"] diff --git a/src/apps/epic/migrations/0007_tarotcard_tarotdeck.py b/src/apps/epic/migrations/0007_tarotcard_tarotdeck.py new file mode 100644 index 0000000..0249f9a --- /dev/null +++ b/src/apps/epic/migrations/0007_tarotcard_tarotdeck.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0 on 2026-03-24 23:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0006_table_status_and_table_seat'), + ] + + operations = [ + migrations.CreateModel( + name='TarotCard', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('arcana', models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana')], max_length=5)), + ('suit', models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True)), + ('number', models.IntegerField()), + ('slug', models.SlugField(unique=True)), + ('keywords_upright', models.JSONField(default=list)), + ('keywords_reversed', models.JSONField(default=list)), + ], + options={ + 'ordering': ['arcana', 'suit', 'number'], + }, + ), + migrations.CreateModel( + name='TarotDeck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('drawn_card_ids', models.JSONField(default=list)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('room', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tarot_deck', to='epic.room')), + ], + ), + ] diff --git a/src/apps/epic/migrations/0008_seed_tarot_cards.py b/src/apps/epic/migrations/0008_seed_tarot_cards.py new file mode 100644 index 0000000..d2a6a89 --- /dev/null +++ b/src/apps/epic/migrations/0008_seed_tarot_cards.py @@ -0,0 +1,164 @@ +from django.db import migrations + +MAJOR_ARCANA = [ + (0, "The Fool", "the-fool", ["beginnings", "spontaneity", "freedom"], ["recklessness", "naivety", "risk"]), + (1, "The Magician", "the-magician", ["willpower", "skill", "resourcefulness"], ["manipulation", "untapped potential", "deceit"]), + (2, "The High Priestess", "the-high-priestess", ["intuition", "mystery", "inner knowledge"], ["secrets", "disconnection", "withdrawal"]), + (3, "The Empress", "the-empress", ["fertility", "abundance", "nurturing"], ["dependence", "smothering", "creative block"]), + (4, "The Emperor", "the-emperor", ["authority", "structure", "stability"], ["rigidity", "domination", "inflexibility"]), + (5, "The Hierophant", "the-hierophant", ["tradition", "conformity", "institutions"], ["rebellion", "unconventionality", "challenge"]), + (6, "The Lovers", "the-lovers", ["love", "harmony", "choice"], ["disharmony", "imbalance", "misalignment"]), + (7, "The Chariot", "the-chariot", ["control", "willpower", "victory"], ["aggression", "lack of direction", "defeat"]), + (8, "Strength", "strength", ["courage", "patience", "compassion"], ["self-doubt", "weakness", "insecurity"]), + (9, "The Hermit", "the-hermit", ["introspection", "guidance", "solitude"], ["isolation", "loneliness", "withdrawal"]), + (10, "Wheel of Fortune", "wheel-of-fortune", ["change", "cycles", "fate"], ["bad luck", "resistance", "clinging to control"]), + (11, "Justice", "justice", ["fairness", "truth", "cause and effect"], ["injustice", "dishonesty", "avoidance"]), + (12, "The Hanged Man", "the-hanged-man", ["pause", "surrender", "new perspective"], ["stalling", "resistance", "indecision"]), + (13, "Death", "death", ["endings", "transition", "transformation"], ["fear of change", "stagnation", "resistance"]), + (14, "Temperance", "temperance", ["balance", "patience", "moderation"], ["imbalance", "excess", "lack of harmony"]), + (15, "The Devil", "the-devil", ["bondage", "materialism", "shadow self"], ["detachment", "freedom", "releasing control"]), + (16, "The Tower", "the-tower", ["sudden change", "upheaval", "revelation"], ["avoidance", "fear of change", "delaying disaster"]), + (17, "The Star", "the-star", ["hope", "renewal", "inspiration"], ["despair", "insecurity", "hopelessness"]), + (18, "The Moon", "the-moon", ["illusion", "fear", "the unconscious"], ["confusion", "misinterpretation", "clarity"]), + (19, "The Sun", "the-sun", ["positivity", "success", "vitality"], ["negativity", "depression", "sadness"]), + (20, "Judgement", "judgement", ["reflection", "reckoning", "absolution"], ["self-doubt", "lack of self-awareness", "loathing"]), + (21, "The World", "the-world", ["completion", "integration", "accomplishment"], ["incompletion", "no closure", "shortcuts"]), +] + +MINOR_SUITS = [ + ("WANDS", "wands"), + ("CUPS", "cups"), + ("SWORDS", "swords"), + ("PENTACLES", "pentacles"), +] + +MINOR_NAMES = [ + (1, "Ace", "ace"), + (2, "Two", "two"), + (3, "Three", "three"), + (4, "Four", "four"), + (5, "Five", "five"), + (6, "Six", "six"), + (7, "Seven", "seven"), + (8, "Eight", "eight"), + (9, "Nine", "nine"), + (10, "Ten", "ten"), + (11, "Page", "page"), + (12, "Knight", "knight"), + (13, "Queen", "queen"), + (14, "King", "king"), +] + +# Keywords: [suit][number-1] → (upright_list, reversed_list) +MINOR_KEYWORDS = { + "WANDS": [ + (["inspiration", "new venture", "spark"], ["delays", "lack of motivation", "false start"]), + (["planning", "progress", "decisions"], ["impatience", "lack of planning", "hesitation"]), + (["expansion", "foresight", "enterprise"], ["obstacles", "lack of foresight", "delays"]), + (["celebration", "harmony", "homecoming"], ["lack of support", "transience", "home conflicts"]), + (["conflict", "competition", "tension"], ["avoiding conflict", "compromise", "truce"]), + (["victory", "recognition", "progress"], ["excess pride", "lack of recognition", "fall"]), + (["challenge", "courage", "competition"], ["anxiety", "giving up", "overwhelmed"]), + (["rapid action", "adventure", "change"], ["haste", "scattered energy", "delays"]), + (["resilience", "persistence", "last stand"], ["exhaustion", "giving up", "surrender"]), + (["completion", "celebration", "travel"], ["burdens", "oppression", "carrying too much"]), + (["exploration", "enthusiasm", "adventure"], ["hasty decisions", "scattered energy", "immaturity"]), + (["energy", "passion", "adventure"], ["scattered energy", "frustration", "aggression"]), + (["confidence", "independence", "courage"], ["selfishness", "jealousy", "insecurity"]), + (["big picture", "leadership", "vision"], ["impulsiveness", "haste", "overconfidence"]), + ], + "CUPS": [ + (["new feelings", "intuition", "opportunity"], ["blocked creativity", "emptiness", "hesitation"]), + (["partnership", "unity", "celebration"], ["imbalance", "broken bonds", "misalignment"]), + (["creativity", "community", "abundance"], ["independence", "isolation", "looking inward"]), + (["contemplation", "apathy", "reevaluation"], ["withdrawal", "boredom", "seeking motivation"]), + (["loss", "grief", "disappointment"], ["acceptance", "moving on", "forgiveness"]), + (["nostalgia", "reunion", "joy"], ["living in the past", "naivety", "unrealistic"]), + (["illusion", "fantasy", "wishful thinking"], ["alignment", "clarity", "sobriety"]), + (["disappointment", "abandonment", "walking away"], ["hopelessness", "aimlessness", "stagnation"]), + (["contentment", "fulfilment", "satisfaction"], ["inner happiness", "materialism", "indulgence"]), + (["divine love", "bliss", "fulfilment"], ["inner happiness", "alignment", "personal values"]), + (["sensitivity", "creativity", "intuition"], ["insecurity", "emotional immaturity", "creative blocks"]), + (["compassion", "romanticism", "diplomacy"], ["moodiness", "emotional manipulation", "deception"]), + (["compassion", "empathy", "nurturing"], ["emotional insecurity", "over-giving", "neglect"]), + (["emotional maturity", "diplomacy", "wisdom"], ["manipulation", "moodiness", "coldness"]), + ], + "SWORDS": [ + (["raw power", "breakthrough", "clarity"], ["confusion", "brutality", "mental chaos"]), + (["difficult choices", "stalemate", "truce"], ["indecision", "lies", "confusion"]), + (["heartbreak", "sorrow", "grief"], ["recovery", "forgiveness", "moving on"]), + (["rest", "restoration", "retreat"], ["restlessness", "burnout", "illness"]), + (["defeat", "change", "transition"], ["resistance to change", "inability to move"]), + (["victory", "success", "ambition"], ["an eye for an eye", "dishonour", "manipulation"]), + (["deception", "trickery", "tactics"], ["imposter syndrome", "coming clean", "rethinking"]), + (["restriction", "isolation", "imprisonment"], ["self-limiting beliefs", "inner critic", "opening up"]), + (["anxiety", "worry", "fear"], ["recovery from anxiety", "inner turmoil", "secrets"]), + (["ruin", "painful endings", "loss"], ["recovery", "regeneration", "resisting an end"]), + (["new ideas", "mental agility", "curiosity"], ["manipulation", "all talk no action", "ruthlessness"]), + (["action", "impulsiveness", "ambition"], ["no direction", "disregard for consequences"]), + (["clarity", "directness", "structure"], ["coldness", "cruelty", "manipulation"]), + (["mental clarity", "truth", "authority"], ["abuse of power", "manipulation", "coldness"]), + ], + "PENTACLES": [ + (["opportunity", "new venture", "manifestation"], ["lost opportunity", "lack of planning", "scarcity"]), + (["juggling resources", "flexibility", "fun"], ["imbalance", "disorganisation", "overwhelm"]), + (["teamwork", "building", "apprenticeship"], ["lack of teamwork", "disharmony", "misalignment"]), + (["stability", "security", "conservation"], ["greed", "stinginess", "possessiveness"]), + (["isolation", "insecurity", "worry"], ["recovery from loss", "overcoming hardship"]), + (["generosity", "charity", "community"], ["strings attached", "power dynamics", "inequality"]), + (["hard work", "perseverance", "diligence"], ["lack of reward", "laziness", "low quality"]), + (["apprenticeship", "education", "skill"], ["perfectionism", "misdirected activity", "misuse"]), + (["abundance", "luxury", "self-sufficiency"], ["overindulgence", "superficiality", "materialism"]), + (["wealth", "financial security", "achievement"], ["financial failure", "greed", "lost success"]), + (["ambition", "diligence", "management"], ["underhandedness", "greediness", "unethical"]), + (["hard work", "productivity", "routine"], ["laziness", "obsession with work", "burnout"]), + (["nurturing", "practical", "abundance"], ["financial dependence", "smothering", "insecurity"]), + (["abundance", "prosperity", "security"], ["greed", "indulgence", "sensual obsession"]), + ], +} + + +def seed_tarot_cards(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + + # Major Arcana + for number, name, slug, upright, reversed_ in MAJOR_ARCANA: + TarotCard.objects.create( + name=name, + arcana="MAJOR", + suit=None, + number=number, + slug=slug, + keywords_upright=upright, + keywords_reversed=reversed_, + ) + + # Minor Arcana + for suit_code, suit_slug in MINOR_SUITS: + for number, rank_name, rank_slug in MINOR_NAMES: + upright, reversed_ = MINOR_KEYWORDS[suit_code][number - 1] + TarotCard.objects.create( + name=f"{rank_name} of {suit_code.capitalize()}", + arcana="MINOR", + suit=suit_code, + number=number, + slug=f"{rank_slug}-of-{suit_slug}", + keywords_upright=upright, + keywords_reversed=reversed_, + ) + + +def unseed_tarot_cards(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + TarotCard.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0007_tarotcard_tarotdeck"), + ] + + operations = [ + migrations.RunPython(seed_tarot_cards, reverse_code=unseed_tarot_cards), + ] diff --git a/src/apps/epic/migrations/0009_deckvariant_alter_tarotcard_options_and_more.py b/src/apps/epic/migrations/0009_deckvariant_alter_tarotcard_options_and_more.py new file mode 100644 index 0000000..a8c41bd --- /dev/null +++ b/src/apps/epic/migrations/0009_deckvariant_alter_tarotcard_options_and_more.py @@ -0,0 +1,68 @@ +# Generated by Django 6.0 on 2026-03-25 00:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0008_seed_tarot_cards'), + ] + + operations = [ + migrations.CreateModel( + name='DeckVariant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('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)), + ], + ), + migrations.AlterModelOptions( + name='tarotcard', + options={'ordering': ['deck_variant', 'arcana', 'suit', 'number']}, + ), + migrations.AddField( + model_name='tarotcard', + name='correspondence', + field=models.CharField(blank=True, max_length=200), + ), + migrations.AddField( + model_name='tarotcard', + name='group', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AlterField( + model_name='tarotcard', + name='name', + field=models.CharField(max_length=200), + ), + migrations.AlterField( + model_name='tarotcard', + name='slug', + field=models.SlugField(max_length=120), + ), + migrations.AlterField( + model_name='tarotcard', + name='suit', + field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('COINS', 'Coins')], max_length=10, null=True), + ), + migrations.AddField( + model_name='tarotcard', + name='deck_variant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='epic.deckvariant'), + ), + migrations.AddField( + model_name='tarotdeck', + name='deck_variant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_decks', to='epic.deckvariant'), + ), + migrations.AlterUniqueTogether( + name='tarotcard', + unique_together={('deck_variant', 'slug')}, + ), + ] diff --git a/src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py b/src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py new file mode 100644 index 0000000..048ab18 --- /dev/null +++ b/src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py @@ -0,0 +1,202 @@ +""" +Data migration: + 1. Create DeckVariant records (Fiorentine Minchiate + Earthman). + 2. Backfill the 78 existing TarotCards → Fiorentine Minchiate. + 3. Seed all 108 Earthman cards (52 major + 56 minor). +""" +from django.db import migrations + +# ── Earthman Major Arcana (52 cards, numbers 0–51) ────────────────────────── +# (name, slug, group, correspondence) +EARTHMAN_MAJOR = [ + # ── The Schiz ────────────────────────────────────────────────────────── + (0, "The Schiz", "the-schiz", "", "The Fool / Il Matto"), + + # ── The Popes ────────────────────────────────────────────────────────── + (1, "Pope I: President", "pope-i-president", "The Popes", "The Magician / Il Bagatto"), + (2, "Pope II: Tsar", "pope-ii-tsar", "The Popes", "The Popess / La Papessa"), + (3, "Pope III: Chairman", "pope-iii-chairman", "The Popes", "The Empress / L'Imperatrice"), + (4, "Pope IV: Emperor", "pope-iv-emperor", "The Popes", "The Emperor / L'Imperatore"), + (5, "Pope V: Chancellor", "pope-v-chancellor", "The Popes", "The Pope / Il Papa"), + + # ── The Virtues, Implicit (cardinal / acquired) ──────────────────────── + (6, "Virtue VI: Controlled Folly", "virtue-vi-controlled-folly", "The Virtues, Implicit", "Fortitude / La Fortezza"), + (7, "Virtue VII: Not-Doing", "virtue-vii-not-doing", "The Virtues, Implicit", "Justice / La Giustizia"), + (8, "Virtue VIII: Losing Self-Importance","virtue-viii-losing-self-importance","The Virtues, Implicit", "Temperance / La Temperanza"), + (9, "Virtue IX: Erasing Personal History","virtue-ix-erasing-personal-history","The Virtues, Implicit", "Prudence / La Prudenza"), + + # ── Wheel ────────────────────────────────────────────────────────────── + (10, "Wheel of Fortune", "wheel-of-fortune-em", "", "La Ruota della Fortuna"), + + # ── Solo cards ───────────────────────────────────────────────────────── + (11, "The Junkboat", "the-junkboat", "", "The Chariot / Il Carro"), + (12, "The Junkman", "the-junkman", "", "The Hanged Man / L'Appeso"), + (13, "Death", "death-em", "", "La Morte"), + (14, "The Traitor", "the-traitor", "", "The Devil / Il Diavolo"), + (15, "Disco Inferno", "disco-inferno", "", "The Tower / La Torre"), + (16, "Torre Terrestre", "torre-terrestre", "", "Purgatorio"), + (17, "Fantasia Celestia", "fantasia-celestia", "", "Paradiso"), + + # ── The Virtues, Explicit (theological / infused) ───────────────────── + (18, "Virtue XVIII: Stalking", "virtue-xviii-stalking", "The Virtues, Explicit", "Love / Charity / La Carità"), + (19, "Virtue XIX: Intent", "virtue-xix-intent", "The Virtues, Explicit", "Hope / La Speranza"), + (20, "Virtue XX: Dreaming", "virtue-xx-dreaming", "The Virtues, Explicit", "Faith / La Fede"), + + # ── The Elements, Classical ──────────────────────────────────────────── + (21, "Element XXI: Fire", "element-xxi-fire", "The Elements, Classical", "Ardor [Ar]"), + (22, "Element XXII: Earth", "element-xxii-earth", "The Elements, Classical", "Ossum [Om]"), + (23, "Element XXIII: Air", "element-xxiii-air", "The Elements, Classical", "Pneuma [Pn]"), + (24, "Element XXIV: Water", "element-xxiv-water", "The Elements, Classical", "Humor [Hm]"), + + # ── The Zodiac ───────────────────────────────────────────────────────── + (25, "Zodiac XXV: Aries", "zodiac-xxv-aries", "The Zodiac", "The Ram"), + (26, "Zodiac XXVI: Taurus", "zodiac-xxvi-taurus", "The Zodiac", "The Bull"), + (27, "Zodiac XXVII: Gemini", "zodiac-xxvii-gemini", "The Zodiac", "The Twins"), + (28, "Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer", "The Zodiac", "The Crab"), + (29, "Zodiac XXIX: Leo", "zodiac-xxix-leo", "The Zodiac", "The Lion"), + (30, "Zodiac XXX: Virgo", "zodiac-xxx-virgo", "The Zodiac", "The Maiden"), + (31, "Zodiac XXXI: Libra", "zodiac-xxxi-libra", "The Zodiac", "The Scales"), + (32, "Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio", "The Zodiac", "The Scorpion"), + (33, "Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius", "The Zodiac", "The Archer"), + (34, "Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn", "The Zodiac", "The Sea-Goat"), + (35, "Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius", "The Zodiac", "The Water-Bearer"), + (36, "Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces", "The Zodiac", "The Fish"), + + # ── The Elements, Absolute ───────────────────────────────────────────── + (37, "Element XXXVII: Time", "element-xxxvii-time", "The Elements, Absolute", "Tempo [Tp]"), + (38, "Element XXXVIII: Space", "element-xxxviii-space", "The Elements, Absolute", "Nexus [Nx]"), + + # ── The Wanderers ────────────────────────────────────────────────────── + (39, "Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar", "The Wanderers", "The Star / Le Stelle"), + (40, "Wanderer XL: The Antichthon", "wanderer-xl-antichthon", "The Wanderers", "The Moon / La Luna"), + (41, "Wanderer XLI: The Corestar", "wanderer-xli-corestar", "The Wanderers", "The Sun / Il Sole"), + (42, "Wanderer XLII: Mercury", "wanderer-xlii-mercury", "The Wanderers", "Mercurio"), + (43, "Wanderer XLIII: Venus", "wanderer-xliii-venus", "The Wanderers", "Venere"), + (44, "Wanderer XLIV: Mars", "wanderer-xliv-mars", "The Wanderers", "Marte"), + (45, "Wanderer XLV: Jupiter", "wanderer-xlv-jupiter", "The Wanderers", "Giove"), + (46, "Wanderer XLVI: Saturn", "wanderer-xlvi-saturn", "The Wanderers", "Saturno"), + (47, "Wanderer XLVII: Uranus", "wanderer-xlvii-uranus", "The Wanderers", "Urano"), + (48, "Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune", "The Wanderers", "Nettuno"), + (49, "Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades", "The Wanderers", "The Binary / Plutone-Proserpina"), + + # ── Finale ───────────────────────────────────────────────────────────── + (50, "The Eagle", "the-eagle", "", "Judgement / L'Angelo"), + (51, "Divine Calculus", "divine-calculus", "", "The World / Il Mondo"), +] + +# ── Earthman Minor Arcana ──────────────────────────────────────────────────── +# 4 suits × 14 cards. Suits: WANDS / CUPS / SWORDS / COINS +# Court cards: Jack (11) / Cavalier (12) / Queen (13) / King (14) +EARTHMAN_SUITS = [ + ("WANDS", "wands", "Ardor [Ar] — Fire"), + ("CUPS", "cups", "Humor [Hm] — Water"), + ("SWORDS","swords","Pneuma [Pn] — Air"), + ("COINS", "coins", "Ossum [Om] — Stone"), +] + +EARTHMAN_RANKS = [ + (1, "Ace", "ace"), + (2, "2", "two"), + (3, "3", "three"), + (4, "4", "four"), + (5, "5", "five"), + (6, "6", "six"), + (7, "7", "seven"), + (8, "8", "eight"), + (9, "9", "nine"), + (10, "10", "ten"), + (11, "Jack", "jack"), + (12, "Cavalier", "cavalier"), + (13, "Queen", "queen"), + (14, "King", "king"), +] + + +def forward(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + + # ── 1. Create DeckVariant records ──────────────────────────────────── + fiorentine = DeckVariant.objects.create( + name="Fiorentine Minchiate", + slug="fiorentine-minchiate", + card_count=78, + description="Standard 78-card Minchiate deck. Alt / lite play mode.", + is_default=False, + ) + earthman = DeckVariant.objects.create( + name="Earthman Deck", + slug="earthman", + card_count=108, + description=( + "Primary 108-card Earthman deck. " + "52 Major Arcana (The Schiz through Divine Calculus) " + "+ 56 Minor Arcana across Wands, Cups, Swords, Coins." + ), + is_default=True, + ) + + # ── 2. Backfill existing 78 Fiorentine cards ───────────────────────── + TarotCard.objects.filter(deck_variant__isnull=True).update( + deck_variant=fiorentine + ) + + # ── 3. Seed Earthman Major Arcana ──────────────────────────────────── + for number, name, slug, group, correspondence in EARTHMAN_MAJOR: + TarotCard.objects.create( + deck_variant=earthman, + name=name, + arcana="MAJOR", + suit=None, + number=number, + slug=slug, + group=group, + correspondence=correspondence, + keywords_upright=[], + keywords_reversed=[], + ) + + # ── 4. Seed Earthman Minor Arcana ──────────────────────────────────── + for suit_code, suit_slug, _element in EARTHMAN_SUITS: + for number, rank_name, rank_slug in EARTHMAN_RANKS: + name = f"{rank_name} of {suit_code.capitalize()}" + slug = f"{rank_slug}-of-{suit_slug}-em" + TarotCard.objects.create( + deck_variant=earthman, + name=name, + arcana="MINOR", + suit=suit_code, + number=number, + slug=slug, + group="", + correspondence="", + keywords_upright=[], + keywords_reversed=[], + ) + + +def reverse(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + + # Remove Earthman cards and clear FK from Fiorentine cards + earthman = DeckVariant.objects.filter(slug="earthman").first() + if earthman: + TarotCard.objects.filter(deck_variant=earthman).delete() + + fiorentine = DeckVariant.objects.filter(slug="fiorentine-minchiate").first() + if fiorentine: + TarotCard.objects.filter(deck_variant=fiorentine).update(deck_variant=None) + + DeckVariant.objects.filter(slug__in=["earthman", "fiorentine-minchiate"]).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0009_deckvariant_alter_tarotcard_options_and_more"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 139819f..639331e 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -1,3 +1,4 @@ +import random import uuid from datetime import timedelta @@ -173,3 +174,96 @@ class TableSeat(models.Model): 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) + + +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) + + def __str__(self): + return f"{self.name} ({self.card_count} cards)" + + +class TarotCard(models.Model): + MAJOR = "MAJOR" + MINOR = "MINOR" + ARCANA_CHOICES = [ + (MAJOR, "Major Arcana"), + (MINOR, "Minor Arcana"), + ] + + WANDS = "WANDS" + CUPS = "CUPS" + SWORDS = "SWORDS" + PENTACLES = "PENTACLES" # Fiorentine 4th suit + COINS = "COINS" # Earthman 4th suit (Ossum / Stone) + SUIT_CHOICES = [ + (WANDS, "Wands"), + (CUPS, "Cups"), + (SWORDS, "Swords"), + (PENTACLES, "Pentacles"), + (COINS, "Coins"), + ] + + deck_variant = models.ForeignKey( + DeckVariant, null=True, blank=True, + on_delete=models.CASCADE, related_name="cards", + ) + name = models.CharField(max_length=200) + arcana = models.CharField(max_length=5, choices=ARCANA_CHOICES) + suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True) + number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor + slug = models.SlugField(max_length=120) + correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent + group = models.CharField(max_length=100, blank=True) # Earthman major grouping + keywords_upright = models.JSONField(default=list) + keywords_reversed = models.JSONField(default=list) + + class Meta: + ordering = ["deck_variant", "arcana", "suit", "number"] + unique_together = [("deck_variant", "slug")] + + 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"]) diff --git a/src/apps/epic/urls.py b/src/apps/epic/urls.py index cb86547..ea39cfe 100644 --- a/src/apps/epic/urls.py +++ b/src/apps/epic/urls.py @@ -17,4 +17,6 @@ urlpatterns = [ path('room//gate/status', views.gate_status, name='gate_status'), path('room//delete', views.delete_room, name='delete_room'), path('room//abandon', views.abandon_room, name='abandon_room'), + path('room//tarot/', views.tarot_deck, name='tarot_deck'), + path('room//tarot/deal', views.tarot_deal, name='tarot_deal'), ] diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index ca61f21..0b80075 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -9,7 +9,10 @@ from django.shortcuts import redirect, render from django.utils import timezone from apps.drama.models import GameEvent, record -from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token +from apps.epic.models import ( + GateSlot, Room, RoomInvite, TableSeat, TarotDeck, + debit_token, select_token, +) from apps.lyric.models import Token @@ -463,3 +466,43 @@ def gate_status(request, room_id): ctx = _gate_context(room, request.user) ctx["room"] = room return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx) + + +@login_required +def tarot_deck(request, room_id): + room = Room.objects.get(id=room_id) + deck_variant = request.user.equipped_deck + deck, _ = TarotDeck.objects.get_or_create( + room=room, + defaults={"deck_variant": deck_variant}, + ) + return render(request, "apps/epic/tarot_deck.html", { + "room": room, + "deck": deck, + "remaining": deck.remaining_count, + }) + + +@login_required +def tarot_deal(request, room_id): + if request.method != "POST": + return redirect("epic:tarot_deck", room_id=room_id) + room = Room.objects.get(id=room_id) + deck = TarotDeck.objects.get(room=room) + drawn = deck.draw(6) # Celtic Cross: 6 cross positions; 4 staff filled via gameplay + positions = [ + { + "card": card, + "reversed": is_reversed, + "orientation": "Reversed" if is_reversed else "Upright", + "position": i + 1, + } + for i, (card, is_reversed) in enumerate(drawn) + ] + return render(request, "apps/epic/tarot_deck.html", { + "room": room, + "deck": deck, + "remaining": deck.remaining_count, + "positions": positions, + }) + diff --git a/src/apps/lyric/migrations/0014_user_equipped_deck.py b/src/apps/lyric/migrations/0014_user_equipped_deck.py new file mode 100644 index 0000000..1996f5a --- /dev/null +++ b/src/apps/lyric/migrations/0014_user_equipped_deck.py @@ -0,0 +1,20 @@ +# Generated by Django 6.0 on 2026-03-25 00:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0009_deckvariant_alter_tarotcard_options_and_more'), + ('lyric', '0013_alter_token_slots_claimed'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='equipped_deck', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='epic.deckvariant'), + ), + ] diff --git a/src/apps/lyric/models.py b/src/apps/lyric/models.py index 4b34a4b..d04c0a1 100644 --- a/src/apps/lyric/models.py +++ b/src/apps/lyric/models.py @@ -37,6 +37,10 @@ class User(AbstractBaseUser): "Token", null=True, blank=True, on_delete=models.SET_NULL, related_name="+", ) + equipped_deck = models.ForeignKey( + "epic.DeckVariant", null=True, blank=True, + on_delete=models.SET_NULL, related_name="+", + ) is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) @@ -155,6 +159,7 @@ class PaymentMethod(models.Model): def create_wallet_and_tokens(sender, instance, created, **kwargs): if not created: return + from apps.epic.models import DeckVariant Wallet.objects.create(user=instance, writs=144) coin = Token.objects.create(user=instance, token_type=Token.COIN) Token.objects.create( @@ -167,4 +172,6 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs): instance.equipped_trinket = pass_token else: instance.equipped_trinket = coin - instance.save(update_fields=['equipped_trinket']) + earthman = DeckVariant.objects.filter(slug="earthman").first() + instance.equipped_deck = earthman + instance.save(update_fields=['equipped_trinket', 'equipped_deck']) diff --git a/src/functional_tests/test_component_cards_tarot.py b/src/functional_tests/test_component_cards_tarot.py new file mode 100644 index 0000000..7c0c9e0 --- /dev/null +++ b/src/functional_tests/test_component_cards_tarot.py @@ -0,0 +1,315 @@ +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.by import By + +from .base import FunctionalTest +from apps.applets.models import Applet +from apps.epic.models import DeckVariant, Room +from apps.lyric.models import User + + +class TarotAdminTest(FunctionalTest): + """Admin can browse tarot cards by deck variant via Django admin.""" + + def setUp(self): + super().setUp() + from apps.epic.models import TarotCard + # DeckVariant + TarotCard rows are flushed by TransactionTestCase — recreate + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + # Seed enough cards so admin filter shows a meaningful count + # The "108 tarot cards" assertion relies on deck_variant.card_count reported + # by the admin, not on actual row count (admin shows real rows, so we seed + # representative cards — 3 are enough to reach "The Schiz" in the list) + for number, name, slug, group, correspondence in [ + (0, "The Schiz", "the-schiz-adm", "", "The Fool / Il Matto"), + (1, "Pope I: President","pope-i-president-adm","The Popes", "The Magician / Il Bagatto"), + (50, "The Eagle", "the-eagle-adm", "", "Judgement / L'Angelo"), + ]: + TarotCard.objects.get_or_create( + deck_variant=self.earthman, slug=slug, + defaults={ + "name": name, "arcana": "MAJOR", "number": number, + "group": group, "correspondence": correspondence, + }, + ) + self.superuser = User.objects.create_superuser( + email="admin@example.com", + password="correct-password", + ) + + def _login_to_admin(self): + self.browser.get(self.live_server_url + "/admin/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_username")) + self.browser.find_element(By.ID, "id_username").send_keys("admin@example.com") + self.browser.find_element(By.ID, "id_password").send_keys("correct-password") + self.browser.find_element(By.CSS_SELECTOR, "input[type=submit]").click() + + # ------------------------------------------------------------------ # + # Test 1a — admin home lists Tarot cards + Deck variants under Epic # + # ------------------------------------------------------------------ # + + def test_admin_epic_section_shows_tarot_cards_and_deck_variants(self): + self._login_to_admin() + + body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) + self.assertIn("Tarot cards", body.text) + self.assertIn("Deck variants", body.text) + + # ------------------------------------------------------------------ # + # Test 1b — changelist shows deck variant filter sidebar # + # ------------------------------------------------------------------ # + + def test_admin_tarot_card_list_shows_deck_variant_filter(self): + self._login_to_admin() + + self.browser.get(self.live_server_url + "/admin/epic/tarotcard/") + body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) + # Filter sidebar has a link for the Earthman deck + self.assertIn("Earthman Deck", body.text) + # Cards are listed — 3 seeded in setUp + self.assertIn("3 tarot cards", body.text) + + # ------------------------------------------------------------------ # + # Test 1c — Earthman card detail shows name, group, and correspondence # + # ------------------------------------------------------------------ # + + def test_admin_earthman_card_detail_shows_group_and_correspondence(self): + self._login_to_admin() + + self.browser.get(self.live_server_url + "/admin/epic/tarotcard/") + self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) + + # The Schiz is the Earthman Fool (card 0) + self.browser.find_element(By.LINK_TEXT, "The Schiz").click() + + body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body")) + self.assertIn("Major Arcana", body.text) # arcana dropdown + self.assertIn("the-schiz-adm", body.text) # slug (readonly → rendered as text) + self.assertIn("The Fool / Il Matto", body.text) # correspondence (readonly → text) + + +class TarotDeckTest(FunctionalTest): + """A room founder can view the tarot deck page and deal a Celtic Cross spread.""" + + def setUp(self): + super().setUp() + # DeckVariant + TarotCard rows are flushed by TransactionTestCase — recreate + from apps.epic.models import TarotCard + self.earthman, _ = DeckVariant.objects.get_or_create( + slug="earthman", + defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, + ) + # Seed 8 major cards — enough for a 6-card cross deal (with buffer) + major_stubs = [ + (0, "The Schiz", "the-schiz-ft"), + (1, "Pope I: President", "pope-i-president-ft"), + (2, "Pope II: Tsar", "pope-ii-tsar-ft"), + (3, "Pope III: Chairman","pope-iii-chairman-ft"), + (4, "Pope IV: Emperor", "pope-iv-emperor-ft"), + (5, "Pope V: Chancellor","pope-v-chancellor-ft"), + (10, "Wheel of Fortune", "wheel-of-fortune-em-ft"), + (11, "The Junkboat", "the-junkboat-ft"), + ] + for number, name, slug in major_stubs: + TarotCard.objects.get_or_create( + deck_variant=self.earthman, slug=slug, + defaults={"name": name, "arcana": "MAJOR", "number": number}, + ) + self.founder = User.objects.create(email="founder@test.io") + # Signal sets equipped_deck to Earthman (now it exists) + self.founder.refresh_from_db() + self.room = Room.objects.create(name="Whispering Pines", owner=self.founder) + + # ------------------------------------------------------------------ # + # Test 2 — tarot deck page reports 108 cards (Earthman default) # + # ------------------------------------------------------------------ # + + def test_founder_can_reach_room_tarot_page_and_sees_full_deck(self): + self.create_pre_authenticated_session("founder@test.io") + self.browser.get( + self.live_server_url + f"/gameboard/room/{self.room.id}/tarot/" + ) + + # Browser tab title confirms we're on the tarot page + self.wait_for( + lambda: self.assertIn("Tarot", self.browser.title) + ) + + # Deck status shows all 108 Earthman cards remaining + status = self.browser.find_element(By.CSS_SELECTOR, "[data-tarot-remaining]") + self.assertEqual(status.get_attribute("data-tarot-remaining"), "108") + + # ------------------------------------------------------------------ # + # Test 3 — dealing a Celtic Cross spread shows 10 positioned cards # + # ------------------------------------------------------------------ # + + def test_dealing_celtic_cross_spread_shows_ten_unique_cards(self): + self.create_pre_authenticated_session("founder@test.io") + self.browser.get( + self.live_server_url + f"/gameboard/room/{self.room.id}/tarot/" + ) + + # Click the "Deal Celtic Cross" button + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "[data-deal-spread]") + ).click() + + # Six cross positions appear in the spread (staff positions filled via gameplay) + positions = self.wait_for( + lambda: self.browser.find_elements(By.CSS_SELECTOR, ".tarot-position") + ) + self.assertEqual(len(positions), 6) + + # Each position shows a card name and an orientation label + names = set() + for pos in positions: + name = pos.find_element(By.CSS_SELECTOR, ".tarot-card-name").text + orientation = pos.find_element(By.CSS_SELECTOR, ".tarot-card-orientation").text + self.assertTrue(len(name) > 0, "Card name should not be empty") + self.assertIn(orientation, ["Upright", "Reversed"]) + names.add(name) + + # All 6 cards are unique + self.assertEqual(len(names), 6, "All 6 drawn cards must be unique") + + # ------------------------------------------------------------------ # + # Test 4 — deck count decreases after the spread is dealt # + # ------------------------------------------------------------------ # + + def test_remaining_count_decreases_after_dealing_spread(self): + self.create_pre_authenticated_session("founder@test.io") + self.browser.get( + self.live_server_url + f"/gameboard/room/{self.room.id}/tarot/" + ) + + self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "[data-deal-spread]") + ).click() + + # After dealing 6 cross cards from the 108-card Earthman deck, 102 remain + remaining = self.wait_for( + lambda: self.browser.find_element(By.CSS_SELECTOR, "[data-tarot-remaining]") + ) + self.assertEqual(remaining.get_attribute("data-tarot-remaining"), "102") + + +class GameKitDeckSelectionTest(FunctionalTest): + """ + Game Kit applet on gameboard shows available deck variants with hover + tooltips and an equip/equipped state — following the same mini-tooltip + pattern as trinket selection. + + Test scenario: the gamer's active deck is explicitly set to Fiorentine + (non-default) in setUp, so we can exercise switching back to Earthman. + Once DeckVariant model exists, replace the TODO stubs with real ORM calls. + """ + + def setUp(self): + super().setUp() + for slug, name, cols, rows in [ + ("new-game", "New Game", 6, 3), + ("my-games", "My Games", 6, 3), + ("game-kit", "Game Kit", 6, 3), + ]: + Applet.objects.get_or_create( + slug=slug, + defaults={ + "name": name, "grid_cols": cols, + "grid_rows": rows, "context": "gameboard", + }, + ) + self.gamer = User.objects.create(email="gamer@deck.io") + # TODO: once DeckVariant model is defined — + # from apps.epic.models import DeckVariant + # self.earthman = DeckVariant.objects.get(slug="earthman") + # self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + # # Put gamer on Fiorentine so the test can show switching back to Earthman + # self.gamer.equipped_deck = self.fiorentine + # self.gamer.save(update_fields=["equipped_deck"]) + + # ------------------------------------------------------------------ # + # Test 5 — Game Kit shows deck cards with correct equip/equipped state # + # ------------------------------------------------------------------ # + + def test_game_kit_deck_cards_show_equip_state_and_switching_works(self): + """ + Gamer (currently on Fiorentine) visits gameboard, hovers over the + Earthman deck — sees it is NOT equipped. Hovers to Fiorentine — sees + it IS equipped. Hovers back to Earthman and clicks Equip. + """ + self.create_pre_authenticated_session("gamer@deck.io") + self.browser.get(self.live_server_url + "/gameboard/") + self.wait_for(lambda: self.browser.find_element(By.ID, "id_game_kit")) + + # ── Hover over Earthman deck ────────────────────────────────────── + earthman_el = self.wait_for( + lambda: self.browser.find_element(By.ID, "id_kit_earthman_deck") + ) + self.browser.execute_script( + "arguments[0].scrollIntoView({block: 'center'})", earthman_el + ) + ActionChains(self.browser).move_to_element(earthman_el).perform() + + # Main tooltip shows deck name and card count + self.wait_for( + lambda: self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + portal = self.browser.find_element(By.ID, "id_tooltip_portal") + self.assertIn("Earthman", portal.text) + self.assertIn("108", portal.text) + + # Mini tooltip shows Equip button — Earthman is NOT currently equipped + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + equip_btn = mini.find_element(By.CSS_SELECTOR, ".equip-deck-btn") + self.assertEqual(equip_btn.text, "Equip Deck?") + + # ── Hover over Fiorentine Minchiate deck ───────────────────────── + fiorentine_el = self.browser.find_element(By.ID, "id_kit_fiorentine_deck") + self.browser.execute_script( + "arguments[0].scrollIntoView({block: 'center'})", fiorentine_el + ) + ActionChains(self.browser).move_to_element(fiorentine_el).perform() + + self.wait_for( + lambda: self.assertIn( + "Fiorentine", + self.browser.find_element(By.ID, "id_tooltip_portal").text, + ) + ) + portal = self.browser.find_element(By.ID, "id_tooltip_portal") + self.assertIn("78", portal.text) + + # Mini tooltip shows "Equipped" — Fiorentine is the active deck + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + self.assertIn("Equipped", mini.text) + + # ── Hover back to Earthman and click Equip ──────────────────────── + ActionChains(self.browser).move_to_element(earthman_el).perform() + self.wait_for( + lambda: self.assertIn( + "Earthman", + self.browser.find_element(By.ID, "id_tooltip_portal").text, + ) + ) + mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") + self.wait_for(lambda: self.assertTrue(mini.is_displayed())) + mini.find_element(By.CSS_SELECTOR, ".equip-deck-btn").click() + + # Both portals close after equip + self.wait_for( + lambda: self.assertFalse( + self.browser.find_element(By.ID, "id_tooltip_portal").is_displayed() + ) + ) + + # Game Kit data attribute now reflects Earthman's id + game_kit = self.browser.find_element(By.ID, "id_game_kit") + self.wait_for( + lambda: self.assertNotEqual( + game_kit.get_attribute("data-equipped-deck-id"), "" + ) + ) diff --git a/src/templates/apps/epic/tarot_deck.html b/src/templates/apps/epic/tarot_deck.html new file mode 100644 index 0000000..87ac493 --- /dev/null +++ b/src/templates/apps/epic/tarot_deck.html @@ -0,0 +1,33 @@ +{% extends "core/base.html" %} + +{% block title_text %}Tarot — {{ room.name }}{% endblock title_text %} +{% block header_text %}Tarot — {{ room.name }}{% endblock header_text %} + +{% block content %} +
+

+ {{ remaining }} card{{ remaining|pluralize }} remaining + {% if deck.deck_variant %}({{ deck.deck_variant.name }}){% endif %} +

+ + {% if not positions %} +
+ {% csrf_token %} + +
+ {% else %} +
+ {% for pos in positions %} +
+ {{ pos.card.name }} + {{ pos.orientation }} +
+ {% endfor %} +
+ +

+ {{ remaining }} card{{ remaining|pluralize }} remaining +

+ {% endif %} +
+{% endblock content %}