diff --git a/src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py b/src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py new file mode 100644 index 0000000..80f6f8e --- /dev/null +++ b/src/apps/epic/migrations/0026_earthman_suit_renames_and_keywords.py @@ -0,0 +1,154 @@ +""" +Data migration — Earthman deck: + 1. Rename three suit codes (and card names) for Earthman cards: + WANDS → BRANDS (Wands → Brands) + CUPS → GRAILS (Cups → Grails) + SWORDS → BLADES (Swords → Blades) + CROWNS stays CROWNS. + 2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate + deck to corresponding Earthman cards: + • Major: explicit number-to-number map based on card correspondences. + • Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS, + BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart + stay with empty keyword lists. +""" +from django.db import migrations + +# ── 1. Suit rename map ──────────────────────────────────────────────────────── + +SUIT_RENAMES = { + "WANDS": "BRANDS", + "CUPS": "GRAILS", + "SWORDS": "BLADES", +} + +# ── 2. Major arcana: Earthman number → Fiorentine number ───────────────────── +# Cards without a Fiorentine counterpart are omitted (keywords stay empty). + +MAJOR_KEYWORD_MAP = { + 0: 0, # The Schiz → The Fool + 1: 1, # Pope I (President) → The Magician + 2: 2, # Pope II (Tsar) → The High Priestess + 3: 3, # Pope III (Chairman) → The Empress + 4: 4, # Pope IV (Emperor) → The Emperor + 5: 5, # Pope V (Chancellor) → The Hierophant + 6: 8, # Virtue VI (Controlled Folly) → Strength + 7: 11, # Virtue VII (Not-Doing) → Justice + 8: 14, # Virtue VIII (Losing Self-Importance) → Temperance + # 9: Prudence — no Fiorentine equivalent + 10: 10, # Wheel of Fortune → Wheel of Fortune + 11: 7, # The Junkboat → The Chariot + 12: 12, # The Junkman → The Hanged Man + 13: 13, # Death → Death + 14: 15, # The Traitor → The Devil + 15: 16, # Disco Inferno → The Tower + # 16: Torre Terrestre (Purgatory) — no equivalent + # 17: Fantasia Celestia (Paradise) — no equivalent + 18: 6, # Virtue XVIII (Stalking) → The Lovers + # 19: Virtue XIX (Intent / Hope) — no equivalent + # 20: Virtue XX (Dreaming / Faith)— no equivalent + # 21–38: Classical Elements + Zodiac — no equivalents + 39: 17, # Wanderer XXXIX (Polestar) → The Star + 40: 18, # Wanderer XL (Antichthon) → The Moon + 41: 19, # Wanderer XLI (Corestar) → The Sun + # 42–49: Planets + The Binary — no equivalents + 50: 20, # The Eagle → Judgement + 51: 21, # Divine Calculus → The World +} + +# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ─────────────────── + +MINOR_SUIT_MAP = { + "BRANDS": "WANDS", + "GRAILS": "CUPS", + "BLADES": "SWORDS", + "CROWNS": "PENTACLES", +} + + +def forward(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + + try: + earthman = DeckVariant.objects.get(slug="earthman") + fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + except DeckVariant.DoesNotExist: + return # decks not seeded — nothing to do + + # ── Step 1: rename Earthman suit codes + card names ─────────────────────── + for old_suit, new_suit in SUIT_RENAMES.items(): + old_display = old_suit.capitalize() # e.g. "Wands" + new_display = new_suit.capitalize() # e.g. "Brands" + cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit) + for card in cards: + card.name = card.name.replace(f" of {old_display}", f" of {new_display}") + card.suit = new_suit + card.save() + + # ── Step 2: copy major arcana keywords ─────────────────────────────────── + fio_major = { + card.number: card + for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR") + } + for em_num, fio_num in MAJOR_KEYWORD_MAP.items(): + fio_card = fio_major.get(fio_num) + if not fio_card: + continue + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=em_num + ).update( + keywords_upright=fio_card.keywords_upright, + keywords_reversed=fio_card.keywords_reversed, + ) + + # ── Step 3: copy minor/middle arcana keywords ───────────────────────────── + for em_suit, fio_suit in MINOR_SUIT_MAP.items(): + fio_by_number = { + card.number: card + for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit) + } + for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit): + fio_card = fio_by_number.get(em_card.number) + if fio_card: + em_card.keywords_upright = fio_card.keywords_upright + em_card.keywords_reversed = fio_card.keywords_reversed + em_card.save() + + +def reverse(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + + # Reverse suit renames + reverse_renames = {new: old for old, new in SUIT_RENAMES.items()} + for new_suit, old_suit in reverse_renames.items(): + new_display = new_suit.capitalize() + old_display = old_suit.capitalize() + cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit) + for card in cards: + card.name = card.name.replace(f" of {new_display}", f" of {old_display}") + card.suit = old_suit + card.save() + + # Clear all Earthman keywords + TarotCard.objects.filter(deck_variant=earthman).update( + keywords_upright=[], + keywords_reversed=[], + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0025_earthman_middle_arcana_and_major_icons"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/migrations/0027_tarotcard_cautions.py b/src/apps/epic/migrations/0027_tarotcard_cautions.py new file mode 100644 index 0000000..9fc9a05 --- /dev/null +++ b/src/apps/epic/migrations/0027_tarotcard_cautions.py @@ -0,0 +1,65 @@ +""" +Schema + data migration: + 1. Add `cautions` JSONField (list, default=[]) to TarotCard. + 2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions. + All other cards default to [] — the UI shows a placeholder when empty. +""" +from django.db import migrations, models + +SCHIZO_CAUTIONS = [ + 'This card will reverse into The Pervert when it' + ' comes under dominion of The Occultist, which in turn' + ' reverses into Pestilence.', + + 'This card will reverse into The Paranoiac when it' + ' comes under dominion of The Despot, which in turn' + ' reverses into War.', + + 'This card will reverse into The Neurotic when it' + ' comes under dominion of The Capitalist, which in turn' + ' reverses into Famine.', + + 'This card will reverse into The Suicidal when it' + ' comes under dominion of The Fascist, which in turn' + ' reverses into Death.', +] + + +def seed_schizo_cautions(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=1 + ).update(cautions=SCHIZO_CAUTIONS) + + +def clear_schizo_cautions(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=1 + ).update(cautions=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0026_earthman_suit_renames_and_keywords"), + ] + + operations = [ + migrations.AddField( + model_name="tarotcard", + name="cautions", + field=models.JSONField(default=list), + ), + migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions), + ] diff --git a/src/apps/epic/migrations/0028_alter_tarotcard_suit.py b/src/apps/epic/migrations/0028_alter_tarotcard_suit.py new file mode 100644 index 0000000..84832ff --- /dev/null +++ b/src/apps/epic/migrations/0028_alter_tarotcard_suit.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-04-07 03:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0027_tarotcard_cautions'), + ] + + operations = [ + migrations.AlterField( + model_name='tarotcard', + name='suit', + field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True), + ), + ] diff --git a/src/apps/epic/migrations/0029_fix_schizo_cautions.py b/src/apps/epic/migrations/0029_fix_schizo_cautions.py new file mode 100644 index 0000000..48f0dea --- /dev/null +++ b/src/apps/epic/migrations/0029_fix_schizo_cautions.py @@ -0,0 +1,61 @@ +""" +Data fix: clear Schizo cautions from The Nomad (number=0) if present, +and ensure they land on The Schizo (number=1). +""" +from django.db import migrations + +SCHIZO_CAUTIONS = [ + 'This card will reverse into I. The Pervert when it' + ' comes under dominion of II. The Occultist, which in turn' + ' reverses into II. Pestilence.', + + 'This card will reverse into I. The Paranoiac when it' + ' comes under dominion of III. The Despot, which in turn' + ' reverses into III. War.', + + 'This card will reverse into I. The Neurotic when it' + ' comes under dominion of IV. The Capitalist, which in turn' + ' reverses into IV. Famine.', + + 'This card will reverse into I. The Suicidal when it' + ' comes under dominion of V. The Fascist, which in turn' + ' reverses into V. Death.', +] + + +def forward(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=0 + ).update(cautions=[]) + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=1 + ).update(cautions=SCHIZO_CAUTIONS) + + +def reverse(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + try: + earthman = DeckVariant.objects.get(slug="earthman") + except DeckVariant.DoesNotExist: + return + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=1 + ).update(cautions=[]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0028_alter_tarotcard_suit"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 743f551..41c0f34 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -216,13 +216,19 @@ class TarotCard(models.Model): CUPS = "CUPS" SWORDS = "SWORDS" PENTACLES = "PENTACLES" # Fiorentine 4th suit - CROWNS = "CROWNS" # Earthman 4th suit (renamed from Pentacles) + CROWNS = "CROWNS" # Earthman 4th suit + BRANDS = "BRANDS" # Earthman Wands + GRAILS = "GRAILS" # Earthman Cups + BLADES = "BLADES" # Earthman Swords SUIT_CHOICES = [ (WANDS, "Wands"), (CUPS, "Cups"), (SWORDS, "Swords"), (PENTACLES, "Pentacles"), (CROWNS, "Crowns"), + (BRANDS, "Brands"), + (GRAILS, "Grails"), + (BLADES, "Blades"), ] deck_variant = models.ForeignKey( @@ -239,6 +245,7 @@ class TarotCard(models.Model): group = models.CharField(max_length=100, blank=True) # Earthman major grouping keywords_upright = models.JSONField(default=list) keywords_reversed = models.JSONField(default=list) + cautions = models.JSONField(default=list) class Meta: ordering = ["deck_variant", "arcana", "suit", "number"] @@ -290,8 +297,16 @@ class TarotCard(models.Model): self.SWORDS: 'fa-gun', self.PENTACLES: 'fa-star', self.CROWNS: 'fa-crown', + self.BRANDS: 'fa-wand-sparkles', + self.GRAILS: 'fa-trophy', + self.BLADES: 'fa-gun', }.get(self.suit, '') + @property + def cautions_json(self): + import json + return json.dumps(self.cautions) + def __str__(self): return self.name @@ -369,9 +384,9 @@ class SigReservation(models.Model): def sig_deck_cards(room): """Return 36 TarotCard objects forming the Significator deck (18 unique × 2). - PC/BC pair → WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique - SC/AC pair → SWORDS + CUPS Middle Arcana court cards (11–14): 8 unique - NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique + PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique + SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique + NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique Total: 18 unique × 2 (levity + gravity piles) = 36 cards. """ deck_variant = room.owner.equipped_deck @@ -380,13 +395,13 @@ def sig_deck_cards(room): wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.WANDS, TarotCard.CROWNS], + suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.SWORDS, TarotCard.CUPS], + suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( @@ -406,13 +421,13 @@ def _sig_unique_cards(room): wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.WANDS, TarotCard.CROWNS], + suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.SWORDS, TarotCard.CUPS], + suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg new file mode 100644 index 0000000..c970f51 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Alchemist.svg @@ -0,0 +1,105 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg new file mode 100644 index 0000000..563e5a0 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Blank.svg @@ -0,0 +1,62 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg new file mode 100644 index 0000000..67c4564 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Builder.svg @@ -0,0 +1,100 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg new file mode 100644 index 0000000..514d7bd --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Economist.svg @@ -0,0 +1,63 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg new file mode 100644 index 0000000..6bb50a0 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Narrator.svg @@ -0,0 +1,96 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg new file mode 100644 index 0000000..cc52014 --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Player.svg @@ -0,0 +1,77 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg new file mode 100644 index 0000000..98c360c --- /dev/null +++ b/src/apps/epic/static/apps/epic/icons/cards-roles/starter-role-Shepherd.svg @@ -0,0 +1,76 @@ + + \ No newline at end of file diff --git a/src/apps/epic/static/apps/epic/sig-select.js b/src/apps/epic/static/apps/epic/sig-select.js index 320c76d..b743a95 100644 --- a/src/apps/epic/static/apps/epic/sig-select.js +++ b/src/apps/epic/static/apps/epic/sig-select.js @@ -5,9 +5,14 @@ var SigSelect = (function () { gravity: ['BC', 'EC', 'AC'], }; - var overlay, deckGrid, stage, stageCard; + var overlay, deckGrid, stage, stageCard, statBlock; + var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl; + var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel; var reserveUrl, userRole, userPolarity; + var _cautionData = []; + var _cautionIdx = 0; + var _focusedCardEl = null; // card currently shown in stage var _reservedCardId = null; // card with active reservation var _stageFrozen = false; // true after OK — stage locks on reserved card @@ -20,8 +25,60 @@ var SigSelect = (function () { // ── Stage ────────────────────────────────────────────────────────────── + function _populateKeywordList(listEl, csv) { + var keywords = csv ? csv.split(',').filter(Boolean) : []; + listEl.innerHTML = keywords.map(function (k) { + return '
Upright
+Reversed
+[Shoptalk forthcoming]
+ + +Upright
+Reversed
+[Shoptalk forthcoming]
+ + +Upright
+Reversed
+Rival Interaction
+[Shoptalk forthcoming]
+ + +