Compare commits

...

3 Commits

20 changed files with 1166 additions and 50 deletions

View File

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

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
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() # 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>/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'),
]

View File

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

View File

@@ -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();

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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):

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);
}
});
// 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);
});

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 }};"
>
<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>