Compare commits
3 Commits
11c85d56d1
...
15ac3216ff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ac3216ff | ||
|
|
2896efa8e0 | ||
|
|
588358a20f |
@@ -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"]
|
||||
|
||||
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
|
||||
|
||||
from datetime import timedelta
|
||||
@@ -173,3 +174,101 @@ 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)
|
||||
|
||||
@property
|
||||
def short_key(self):
|
||||
"""First dash-separated word of slug — used as an HTML id component."""
|
||||
return self.slug.split('-')[0]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.card_count} cards)"
|
||||
|
||||
|
||||
class TarotCard(models.Model):
|
||||
MAJOR = "MAJOR"
|
||||
MINOR = "MINOR"
|
||||
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>/delete', views.delete_room, name='delete_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 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,
|
||||
})
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ function initGameKitTooltips() {
|
||||
const gameKit = document.getElementById('id_game_kit');
|
||||
if (!portal || !miniPortal || !gameKit) return;
|
||||
|
||||
// Start portals hidden — ensures is_displayed() works correctly in tests
|
||||
// that run without CSS (StaticLiveServerTestCase).
|
||||
portal.style.display = 'none';
|
||||
miniPortal.style.display = 'none';
|
||||
|
||||
let equippedId = gameKit.dataset.equippedId || '';
|
||||
let activeToken = null;
|
||||
let equipping = false;
|
||||
@@ -19,8 +24,9 @@ function initGameKitTooltips() {
|
||||
|
||||
function closePortals() {
|
||||
portal.classList.remove('active');
|
||||
portal.style.display = 'none';
|
||||
miniPortal.classList.remove('active');
|
||||
miniPortal.style.display = '';
|
||||
miniPortal.style.display = 'none';
|
||||
activeToken = null;
|
||||
}
|
||||
|
||||
@@ -44,7 +50,39 @@ function initGameKitTooltips() {
|
||||
}
|
||||
});
|
||||
|
||||
function buildMiniContent(tokenId) {
|
||||
// buildMiniContent takes the full element so it can inspect data-deck-id vs data-token-id.
|
||||
function buildMiniContent(token) {
|
||||
const deckId = token.dataset.deckId;
|
||||
const tokenId = token.dataset.tokenId;
|
||||
|
||||
if (deckId) {
|
||||
const equippedDeckId = gameKit.dataset.equippedDeckId || '';
|
||||
if (equippedDeckId && deckId === equippedDeckId) {
|
||||
miniPortal.textContent = 'Equipped';
|
||||
} else {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'equip-deck-btn';
|
||||
btn.textContent = 'Equip Deck?';
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
equipping = true;
|
||||
gameKit.dataset.equippedDeckId = deckId;
|
||||
fetch(`/gameboard/equip-deck/${deckId}/`, {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': getCsrfToken()},
|
||||
}).then(r => {
|
||||
if (r.ok && equipping) {
|
||||
equipping = false;
|
||||
closePortals();
|
||||
} else {
|
||||
equipping = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
miniPortal.innerHTML = '';
|
||||
miniPortal.appendChild(btn);
|
||||
}
|
||||
} else if (tokenId) {
|
||||
if (equippedId && tokenId === equippedId) {
|
||||
miniPortal.textContent = 'Equipped';
|
||||
} else {
|
||||
@@ -73,6 +111,7 @@ function initGameKitTooltips() {
|
||||
miniPortal.appendChild(btn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function showPortals(token) {
|
||||
equipping = false;
|
||||
@@ -80,18 +119,19 @@ function initGameKitTooltips() {
|
||||
const tooltip = token.querySelector('.token-tooltip');
|
||||
portal.innerHTML = tooltip.innerHTML;
|
||||
portal.classList.add('active');
|
||||
portal.style.display = 'block';
|
||||
|
||||
const isEquippable = !!token.dataset.tokenId;
|
||||
const isEquippable = !!(token.dataset.tokenId || token.dataset.deckId);
|
||||
let miniHeight = 0;
|
||||
|
||||
if (isEquippable) {
|
||||
buildMiniContent(token.dataset.tokenId);
|
||||
buildMiniContent(token);
|
||||
miniPortal.classList.add('active');
|
||||
miniPortal.style.display = 'block';
|
||||
miniHeight = miniPortal.offsetHeight + 4;
|
||||
} else {
|
||||
miniPortal.classList.remove('active');
|
||||
miniPortal.style.display = '';
|
||||
miniPortal.style.display = 'none';
|
||||
}
|
||||
|
||||
const tokenRect = token.getBoundingClientRect();
|
||||
|
||||
@@ -50,8 +50,11 @@ class GameboardViewTest(TestCase):
|
||||
def test_game_kit_has_free_token(self):
|
||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
|
||||
|
||||
def test_game_kit_has_card_deck_placeholder(self):
|
||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck")
|
||||
def test_game_kit_shows_deck_variant_cards(self):
|
||||
decks = self.parsed.cssselect("#id_game_kit .deck-variant")
|
||||
self.assertGreater(len(decks), 0)
|
||||
# Earthman deck (seeded by migration) should have its own card
|
||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")
|
||||
|
||||
def test_game_kit_has_dice_set_placeholder(self):
|
||||
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")
|
||||
|
||||
@@ -7,5 +7,6 @@ urlpatterns = [
|
||||
path('', views.gameboard, name='gameboard'),
|
||||
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'),
|
||||
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
||||
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
|
||||
]
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.utils import timezone
|
||||
|
||||
from apps.applets.utils import applet_context
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.epic.models import Room, RoomInvite
|
||||
from apps.epic.models import DeckVariant, Room, RoomInvite
|
||||
from apps.lyric.models import Token
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ def gameboard(request):
|
||||
"coin": coin,
|
||||
"carte": carte,
|
||||
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||
"equipped_deck_id": str(request.user.equipped_deck_id or ""),
|
||||
"deck_variants": list(DeckVariant.objects.all()),
|
||||
"free_tokens": free_tokens,
|
||||
"free_count": len(free_tokens),
|
||||
"applets": applet_context(request.user, "gameboard"),
|
||||
@@ -59,6 +61,8 @@ def toggle_game_applets(request):
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
||||
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""),
|
||||
"equipped_deck_id": str(request.user.equipped_deck_id or ""),
|
||||
"deck_variants": list(DeckVariant.objects.all()),
|
||||
"free_tokens": list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at")),
|
||||
@@ -86,3 +90,13 @@ def equip_trinket(request, token_id):
|
||||
"apps/gameboard/_partials/_equip_trinket_btn.html",
|
||||
{"token": token},
|
||||
)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def equip_deck(request, deck_id):
|
||||
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
||||
if request.method == "POST":
|
||||
request.user.equipped_deck = deck
|
||||
request.user.save(update_fields=["equipped_deck"])
|
||||
return HttpResponse(status=204)
|
||||
return HttpResponse(status=405)
|
||||
|
||||
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,
|
||||
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'])
|
||||
|
||||
@@ -221,6 +221,30 @@ class CarteTokenCreationTest(TestCase):
|
||||
self.assertIsNone(token.expires_at)
|
||||
|
||||
|
||||
class EquippedDeckTest(TestCase):
|
||||
def test_new_user_gets_earthman_as_default_deck(self):
|
||||
from apps.epic.models import DeckVariant
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
user = User.objects.create(email="deck@test.io")
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.equipped_deck, earthman)
|
||||
|
||||
def test_fiorentine_is_not_auto_assigned_to_new_users(self):
|
||||
from apps.epic.models import DeckVariant
|
||||
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||
user = User.objects.create(email="deck2@test.io")
|
||||
user.refresh_from_db()
|
||||
self.assertNotEqual(user.equipped_deck, fiorentine)
|
||||
|
||||
def test_equipped_deck_can_be_switched(self):
|
||||
from apps.epic.models import DeckVariant
|
||||
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||
user = User.objects.create(email="deck3@test.io")
|
||||
user.equipped_deck = fiorentine
|
||||
user.save(update_fields=["equipped_deck"])
|
||||
self.assertEqual(User.objects.get(pk=user.pk).equipped_deck, fiorentine)
|
||||
|
||||
|
||||
class PaymentMethodTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="pay@test.io")
|
||||
|
||||
@@ -154,16 +154,19 @@ class BillscrollPositionTest(FunctionalTest):
|
||||
)
|
||||
|
||||
# 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase),
|
||||
# set position, and dispatch scroll event to trigger the debounced save.
|
||||
# JS saves scrollTop + clientHeight (bottom-of-viewport); forced height is 150px.
|
||||
# set position, dispatch scroll event, and capture the position value the
|
||||
# JS listener will save — all in one synchronous script so the layout
|
||||
# snapshot is identical to what the scroll handler sees.
|
||||
scroll_top = 100
|
||||
forced_height = 150
|
||||
self.browser.execute_script("""
|
||||
saved_pos = self.browser.execute_script("""
|
||||
var el = arguments[0];
|
||||
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
el.style.overflow = 'auto';
|
||||
el.style.height = '150px';
|
||||
el.scrollTop = arguments[1];
|
||||
var pos = Math.round(el.scrollTop + el.clientHeight + remPx * 2.5);
|
||||
el.dispatchEvent(new Event('scroll'));
|
||||
return pos;
|
||||
""", scroll_el, scroll_top)
|
||||
|
||||
# 3. Wait for debounce (800ms) + fetch to complete
|
||||
@@ -180,11 +183,8 @@ class BillscrollPositionTest(FunctionalTest):
|
||||
scroll_el = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||
)
|
||||
buffer_px = self.browser.execute_script(
|
||||
"return Math.round(parseFloat(getComputedStyle(document.documentElement).fontSize) * 2.5)"
|
||||
)
|
||||
restored = int(scroll_el.get_attribute("data-scroll-position"))
|
||||
self.assertEqual(restored, scroll_top + forced_height + buffer_px)
|
||||
self.assertEqual(restored, saved_pos)
|
||||
|
||||
|
||||
class BillboardAppletsTest(FunctionalTest):
|
||||
|
||||
323
src/functional_tests/test_component_cards_tarot.py
Normal file
323
src/functional_tests/test_component_cards_tarot.py
Normal file
@@ -0,0 +1,323 @@
|
||||
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",
|
||||
},
|
||||
)
|
||||
# DeckVariant rows are flushed by TransactionTestCase — recreate before
|
||||
# creating the user so the post_save signal can set equipped_deck = earthman.
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
self.fiorentine, _ = DeckVariant.objects.get_or_create(
|
||||
slug="fiorentine-minchiate",
|
||||
defaults={"name": "Fiorentine Minchiate", "card_count": 78, "is_default": False},
|
||||
)
|
||||
self.gamer = User.objects.create(email="gamer@deck.io")
|
||||
# Signal sets equipped_deck = earthman (now it exists); put gamer on
|
||||
# Fiorentine so the test can exercise switching back to Earthman.
|
||||
self.gamer.refresh_from_db()
|
||||
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"), ""
|
||||
)
|
||||
)
|
||||
@@ -22,8 +22,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Restore: position stored is bottom-of-viewport; subtract clientHeight to align it
|
||||
// Only restore if there's a meaningful saved position — avoids a
|
||||
// no-op scrollTop assignment (0→0) that can fire a spurious scroll
|
||||
// event and reset the debounce timer in tests / headless browsers.
|
||||
if ({{ scroll_position }} > 0) {
|
||||
scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight);
|
||||
}
|
||||
});
|
||||
|
||||
// Animate "What happens next. . . ?" buffer dots — 4th span shows '?'
|
||||
@@ -37,21 +41,24 @@
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Debounced save on scroll — store bottom-of-viewport so the last-read line is restored
|
||||
// Debounced save on scroll — store bottom-of-viewport so the last-read line is restored.
|
||||
// Position is captured at event time so layout changes during the debounce window
|
||||
// (e.g. rAF adjusting marginTop) don't produce a stale clientHeight.
|
||||
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
var saveTimer;
|
||||
scroll.addEventListener('scroll', function() {
|
||||
var pos = Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5);
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(function() {
|
||||
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
|
||||
var token = csrfToken ? csrfToken.value : '';
|
||||
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
fetch("{% url 'billboard:save_scroll_position' room.id %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': token,
|
||||
},
|
||||
body: 'position=' + Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5),
|
||||
body: 'position=' + pos,
|
||||
});
|
||||
}, 800);
|
||||
});
|
||||
|
||||
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 %}
|
||||
@@ -3,7 +3,7 @@
|
||||
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
|
||||
>
|
||||
<h2>Game Kit</h2>
|
||||
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}">
|
||||
<div id="id_game_kit" data-equipped-id="{{ equipped_trinket_id }}" data-equipped-deck-id="{{ equipped_deck_id }}">
|
||||
{% if pass_token %}
|
||||
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
|
||||
<i class="fa-solid fa-clipboard"></i>
|
||||
@@ -66,7 +66,19 @@
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% for deck in deck_variants %}
|
||||
<div id="id_kit_{{ deck.short_key }}_deck" class="token deck-variant" data-deck-id="{{ deck.pk }}">
|
||||
<i class="fa-regular fa-id-badge"></i>
|
||||
<div class="token-tooltip">
|
||||
<div class="token-tooltip-body">
|
||||
<h4>{{ deck.name }}</h4>
|
||||
<p>{{ deck.card_count }} cards</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div id="id_kit_card_deck" class="kit-item"><i class="fa-regular fa-id-badge"></i></div>
|
||||
{% endfor %}
|
||||
<div id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user