added default Earthman 108-card tarot deck, 78-card Minchiate Fiorentine deck, admin tests for each; DeckVariant model governs deck toggle; ran new migrations for apps.epic, apps.lyric; seeded DeckVariant migration to ensure Earthman is default deck; added min. tarot url; most new FTs passing
This commit is contained in:
@@ -1,3 +1,18 @@
|
|||||||
from django.contrib import admin
|
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"]
|
||||||
|
|||||||
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal file
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal file
@@ -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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal file
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal file
@@ -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),
|
||||||
|
]
|
||||||
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal file
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal file
@@ -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),
|
||||||
|
]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import random
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from datetime import timedelta
|
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 = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
||||||
role_revealed = models.BooleanField(default=False)
|
role_revealed = models.BooleanField(default=False)
|
||||||
seat_position = models.IntegerField(null=True, blank=True)
|
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"])
|
||||||
|
|||||||
@@ -17,4 +17,6 @@ urlpatterns = [
|
|||||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
|
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
|
||||||
|
path('room/<uuid:room_id>/tarot/', views.tarot_deck, name='tarot_deck'),
|
||||||
|
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ from django.shortcuts import redirect, render
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.drama.models import GameEvent, record
|
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
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
@@ -463,3 +466,43 @@ def gate_status(request, room_id):
|
|||||||
ctx = _gate_context(room, request.user)
|
ctx = _gate_context(room, request.user)
|
||||||
ctx["room"] = room
|
ctx["room"] = room
|
||||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|||||||
20
src/apps/lyric/migrations/0014_user_equipped_deck.py
Normal file
20
src/apps/lyric/migrations/0014_user_equipped_deck.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -37,6 +37,10 @@ class User(AbstractBaseUser):
|
|||||||
"Token", null=True, blank=True,
|
"Token", null=True, blank=True,
|
||||||
on_delete=models.SET_NULL, related_name="+",
|
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_staff = models.BooleanField(default=False)
|
||||||
is_superuser = 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):
|
def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
||||||
if not created:
|
if not created:
|
||||||
return
|
return
|
||||||
|
from apps.epic.models import DeckVariant
|
||||||
Wallet.objects.create(user=instance, writs=144)
|
Wallet.objects.create(user=instance, writs=144)
|
||||||
coin = Token.objects.create(user=instance, token_type=Token.COIN)
|
coin = Token.objects.create(user=instance, token_type=Token.COIN)
|
||||||
Token.objects.create(
|
Token.objects.create(
|
||||||
@@ -167,4 +172,6 @@ def create_wallet_and_tokens(sender, instance, created, **kwargs):
|
|||||||
instance.equipped_trinket = pass_token
|
instance.equipped_trinket = pass_token
|
||||||
else:
|
else:
|
||||||
instance.equipped_trinket = coin
|
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'])
|
||||||
|
|||||||
315
src/functional_tests/test_component_cards_tarot.py
Normal file
315
src/functional_tests/test_component_cards_tarot.py
Normal file
@@ -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"), ""
|
||||||
|
)
|
||||||
|
)
|
||||||
33
src/templates/apps/epic/tarot_deck.html
Normal file
33
src/templates/apps/epic/tarot_deck.html
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
|
||||||
|
{% block title_text %}Tarot — {{ room.name }}{% endblock title_text %}
|
||||||
|
{% block header_text %}<span>Tarot</span> — {{ room.name }}{% endblock header_text %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="tarot-page">
|
||||||
|
<p data-tarot-remaining="{{ remaining }}">
|
||||||
|
{{ remaining }} card{{ remaining|pluralize }} remaining
|
||||||
|
{% if deck.deck_variant %}({{ deck.deck_variant.name }}){% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if not positions %}
|
||||||
|
<form method="post" action="{% url 'epic:tarot_deal' room.id %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" data-deal-spread>Deal Celtic Cross</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="tarot-spread">
|
||||||
|
{% for pos in positions %}
|
||||||
|
<div class="tarot-position" data-position="{{ pos.position }}">
|
||||||
|
<span class="tarot-card-name">{{ pos.card.name }}</span>
|
||||||
|
<span class="tarot-card-orientation">{{ pos.orientation }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p data-tarot-remaining="{{ remaining }}">
|
||||||
|
{{ remaining }} card{{ remaining|pluralize }} remaining
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
Reference in New Issue
Block a user