Compare commits

...

3 Commits

20 changed files with 1166 additions and 50 deletions

View File

@@ -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"]

View 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')),
],
),
]

View 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),
]

View File

@@ -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')},
),
]

View 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 051) ──────────────────────────
# (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),
]

View File

@@ -1,3 +1,4 @@
import random
import uuid import uuid
from datetime import timedelta 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 = 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)
@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() # 021 major (Fiorentine); 051 major (Earthman); 114 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"])

View File

@@ -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'),
] ]

View File

@@ -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,
})

View File

@@ -9,6 +9,11 @@ function initGameKitTooltips() {
const gameKit = document.getElementById('id_game_kit'); const gameKit = document.getElementById('id_game_kit');
if (!portal || !miniPortal || !gameKit) return; 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 equippedId = gameKit.dataset.equippedId || '';
let activeToken = null; let activeToken = null;
let equipping = false; let equipping = false;
@@ -19,8 +24,9 @@ function initGameKitTooltips() {
function closePortals() { function closePortals() {
portal.classList.remove('active'); portal.classList.remove('active');
portal.style.display = 'none';
miniPortal.classList.remove('active'); miniPortal.classList.remove('active');
miniPortal.style.display = ''; miniPortal.style.display = 'none';
activeToken = null; 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) { if (equippedId && tokenId === equippedId) {
miniPortal.textContent = 'Equipped'; miniPortal.textContent = 'Equipped';
} else { } else {
@@ -73,6 +111,7 @@ function initGameKitTooltips() {
miniPortal.appendChild(btn); miniPortal.appendChild(btn);
} }
} }
}
function showPortals(token) { function showPortals(token) {
equipping = false; equipping = false;
@@ -80,18 +119,19 @@ function initGameKitTooltips() {
const tooltip = token.querySelector('.token-tooltip'); const tooltip = token.querySelector('.token-tooltip');
portal.innerHTML = tooltip.innerHTML; portal.innerHTML = tooltip.innerHTML;
portal.classList.add('active'); portal.classList.add('active');
portal.style.display = 'block';
const isEquippable = !!token.dataset.tokenId; const isEquippable = !!(token.dataset.tokenId || token.dataset.deckId);
let miniHeight = 0; let miniHeight = 0;
if (isEquippable) { if (isEquippable) {
buildMiniContent(token.dataset.tokenId); buildMiniContent(token);
miniPortal.classList.add('active'); miniPortal.classList.add('active');
miniPortal.style.display = 'block'; miniPortal.style.display = 'block';
miniHeight = miniPortal.offsetHeight + 4; miniHeight = miniPortal.offsetHeight + 4;
} else { } else {
miniPortal.classList.remove('active'); miniPortal.classList.remove('active');
miniPortal.style.display = ''; miniPortal.style.display = 'none';
} }
const tokenRect = token.getBoundingClientRect(); const tokenRect = token.getBoundingClientRect();

View File

@@ -50,8 +50,11 @@ class GameboardViewTest(TestCase):
def test_game_kit_has_free_token(self): def test_game_kit_has_free_token(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token") [_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")
def test_game_kit_has_card_deck_placeholder(self): def test_game_kit_shows_deck_variant_cards(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_card_deck") 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): def test_game_kit_has_dice_set_placeholder(self):
[_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set") [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")

View File

@@ -7,5 +7,6 @@ urlpatterns = [
path('', views.gameboard, name='gameboard'), path('', views.gameboard, name='gameboard'),
path('toggle-applets', views.toggle_game_applets, name='toggle_game_applets'), 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-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
] ]

View File

@@ -6,7 +6,7 @@ from django.utils import timezone
from apps.applets.utils import applet_context from apps.applets.utils import applet_context
from apps.applets.models import Applet, UserApplet 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 from apps.lyric.models import Token
@@ -31,6 +31,8 @@ def gameboard(request):
"coin": coin, "coin": coin,
"carte": carte, "carte": carte,
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "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_tokens": free_tokens,
"free_count": len(free_tokens), "free_count": len(free_tokens),
"applets": applet_context(request.user, "gameboard"), "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(), "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(), "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
"equipped_trinket_id": str(request.user.equipped_trinket_id or ""), "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( "free_tokens": list(request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now() token_type=Token.FREE, expires_at__gt=timezone.now()
).order_by("expires_at")), ).order_by("expires_at")),
@@ -86,3 +90,13 @@ def equip_trinket(request, token_id):
"apps/gameboard/_partials/_equip_trinket_btn.html", "apps/gameboard/_partials/_equip_trinket_btn.html",
{"token": token}, {"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)

View 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'),
),
]

View File

@@ -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'])

View File

@@ -221,6 +221,30 @@ class CarteTokenCreationTest(TestCase):
self.assertIsNone(token.expires_at) 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): class PaymentMethodTest(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="pay@test.io") self.user = User.objects.create(email="pay@test.io")

View File

@@ -154,16 +154,19 @@ class BillscrollPositionTest(FunctionalTest):
) )
# 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase), # 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase),
# set position, and dispatch scroll event to trigger the debounced save. # set position, dispatch scroll event, and capture the position value the
# JS saves scrollTop + clientHeight (bottom-of-viewport); forced height is 150px. # JS listener will save — all in one synchronous script so the layout
# snapshot is identical to what the scroll handler sees.
scroll_top = 100 scroll_top = 100
forced_height = 150 saved_pos = self.browser.execute_script("""
self.browser.execute_script("""
var el = arguments[0]; var el = arguments[0];
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
el.style.overflow = 'auto'; el.style.overflow = 'auto';
el.style.height = '150px'; el.style.height = '150px';
el.scrollTop = arguments[1]; el.scrollTop = arguments[1];
var pos = Math.round(el.scrollTop + el.clientHeight + remPx * 2.5);
el.dispatchEvent(new Event('scroll')); el.dispatchEvent(new Event('scroll'));
return pos;
""", scroll_el, scroll_top) """, scroll_el, scroll_top)
# 3. Wait for debounce (800ms) + fetch to complete # 3. Wait for debounce (800ms) + fetch to complete
@@ -180,11 +183,8 @@ class BillscrollPositionTest(FunctionalTest):
scroll_el = self.wait_for( scroll_el = self.wait_for(
lambda: self.browser.find_element(By.ID, "id_drama_scroll") 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")) 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): class BillboardAppletsTest(FunctionalTest):

View 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"), ""
)
)

View File

@@ -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); scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight);
}
}); });
// Animate "What happens next. . . ?" buffer dots — 4th span shows '?' // Animate "What happens next. . . ?" buffer dots — 4th span shows '?'
@@ -37,21 +41,24 @@
}, 400); }, 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; var saveTimer;
scroll.addEventListener('scroll', function() { scroll.addEventListener('scroll', function() {
var pos = Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5);
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = setTimeout(function() { saveTimer = setTimeout(function() {
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]');
var token = csrfToken ? csrfToken.value : ''; var token = csrfToken ? csrfToken.value : '';
var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
fetch("{% url 'billboard:save_scroll_position' room.id %}", { fetch("{% url 'billboard:save_scroll_position' room.id %}", {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': token, 'X-CSRFToken': token,
}, },
body: 'position=' + Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5), body: 'position=' + pos,
}); });
}, 800); }, 800);
}); });

View 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 %}

View File

@@ -3,7 +3,7 @@
style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};"
> >
<h2>Game Kit</h2> <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 %} {% if pass_token %}
<div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}"> <div id="id_kit_pass" class="token" data-token-id="{{ pass_token.pk }}">
<i class="fa-solid fa-clipboard"></i> <i class="fa-solid fa-clipboard"></i>
@@ -66,7 +66,19 @@
</div> </div>
{% endwith %} {% endwith %}
{% endif %} {% 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> <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 id="id_kit_dice_set" class="kit-item"><i class="fa-solid fa-dice"></i></div>
</div> </div>
</section> </section>