From da57106d7a47546e9ead1a8ca786c25674dba50d Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Thu, 30 Apr 2026 23:36:35 -0400 Subject: [PATCH] =?UTF-8?q?castanedan=20virtues=20+=20card=2049=20tweak;?= =?UTF-8?q?=20italic=5Fword=20for=20trumps=2019=E2=80=9321;=20sig/sea=20pr?= =?UTF-8?q?opagation=20=E2=80=94=20TDD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 0016: card 49 gravity_reversal All-Bestowing → Bestowing - migration 0017: implicit virtues (trumps 6–9) Sublimating/Sedimentary qualifiers + shared reversals (Indulged Folly / Indulgent Doing / Self-Indulgence / Indulging Personal History); explicit virtues (trumps 19–21) full-string emanation/reversal overrides (The Hunter's/Sleeper's/Quarry's etc.); canonicalize trump 7 name "Not Doing" → "Not-Doing" - migrations 0018+0019: TarotCard.italic_word field; populated for trumps 19–21 (Stalking / Dreaming / Intent) - _tarot_fan.html: data-italic-word + |italicize:card.italic_word filter applied to all rendered title slots - new templatetags/tarot_filters.py: italicize(text, word) — escape-safe wrapping - StageCard JS: parse data-italic-word; new _escape / _italicize / _setTitle helpers wrap matching word in via innerHTML when present (textContent otherwise) - views.py _card_dict: include polarity-split overrides + italic_word so Sea Select stage gets them via fetch JSON - _sig_select_overlay.html: emit the five new data-* attrs on sig-card markup so Sig Select stage picks them up via StageCard.fromDataset Code architected by Disco DeDisco Git commit message Co-Authored-By: Claude Sonnet 4.6 --- .../migrations/0016_card49_bestowing_eagle.py | 37 ++++++ .../migrations/0017_castanedan_virtues.py | 114 ++++++++++++++++++ .../epic/migrations/0018_add_italic_word.py | 18 +++ .../0019_explicit_virtues_italic_word.py | 51 ++++++++ src/apps/epic/models.py | 1 + src/apps/epic/static/apps/epic/stage-card.js | 49 ++++++-- src/apps/epic/templatetags/__init__.py | 0 src/apps/epic/templatetags/tarot_filters.py | 24 ++++ src/apps/epic/views.py | 7 ++ .../_partials/_sig_select_overlay.html | 7 +- .../apps/gameboard/_partials/_tarot_fan.html | 16 +-- 11 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 src/apps/epic/migrations/0016_card49_bestowing_eagle.py create mode 100644 src/apps/epic/migrations/0017_castanedan_virtues.py create mode 100644 src/apps/epic/migrations/0018_add_italic_word.py create mode 100644 src/apps/epic/migrations/0019_explicit_virtues_italic_word.py create mode 100644 src/apps/epic/templatetags/__init__.py create mode 100644 src/apps/epic/templatetags/tarot_filters.py diff --git a/src/apps/epic/migrations/0016_card49_bestowing_eagle.py b/src/apps/epic/migrations/0016_card49_bestowing_eagle.py new file mode 100644 index 0000000..551618f --- /dev/null +++ b/src/apps/epic/migrations/0016_card49_bestowing_eagle.py @@ -0,0 +1,37 @@ +"""Tweak card 49 gravity_reversal: 'All-Bestowing Eagle' → 'Bestowing Eagle'.""" +from django.db import migrations + + +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=49, + ).update(gravity_reversal="The Bestowing Eagle") + + +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=49, + ).update(gravity_reversal="The All-Bestowing Eagle") + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0015_card49_polarity_reversal_titles"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/migrations/0017_castanedan_virtues.py b/src/apps/epic/migrations/0017_castanedan_virtues.py new file mode 100644 index 0000000..d0df690 --- /dev/null +++ b/src/apps/epic/migrations/0017_castanedan_virtues.py @@ -0,0 +1,114 @@ +"""Populate the seven Castanedan Virtues — trumps 6–9 (Implicit) + 19–21 (Explicit). + +Implicit Virtues (6–9): emanation qualifier differs by polarity (Sublimating / +Sedimentary), name is shared. Reversal is a single full string shared across +both polarities (the agency word — Controlled / Not / Losing / Erasing — +flips to Indulged / Indulgent / Self-Indulgence / Indulging). We fill the +standard `levity_qualifier` / `gravity_qualifier` slots so the major-arcana +upright renders "Controlled Folly,\nSublimating" via the existing template +branch; we fill BOTH `levity_reversal` + `gravity_reversal` with the same +string so a FLIP'd reversal still picks up the override (an empty side falls +through to the default major-arcana rendering). + +Explicit Virtues (19–21): emanation is shared across polarities (e.g. "The +Hunter's Stalking" — no qualifier + stem decomposition), reversal differs by +polarity. All four polarity-split title fields filled. + +Also canonicalizes trump 7's name from "Not Doing" to "Not-Doing" per the spec +doc (slug "not-doing" already correct). +""" +from django.db import migrations + + +IMPLICIT = [ + # (number, levity_qualifier, gravity_qualifier, reversal_title) + (6, "Sublimating", "Sedimentary", "Indulged Folly"), + (7, "Sublimating", "Sedimentary", "Indulgent Doing"), + (8, "Sublimating", "Sedimentary", "Self-Indulgence"), + (9, "Sublimating", "Sedimentary", "Indulging Personal History"), +] + +EXPLICIT = [ + # (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal) + (19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking"), + (20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming"), + (21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent"), +] + + +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 + + # Trump 7 name canonicalization + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=7, + ).update(name="Not-Doing") + + for number, lvty, grav, rev in IMPLICIT: + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=number, + ).update( + levity_qualifier=lvty, + gravity_qualifier=grav, + levity_reversal=rev, + gravity_reversal=rev, + ) + + for number, le, ge, lr, gr in EXPLICIT: + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=number, + ).update( + levity_emanation=le, + gravity_emanation=ge, + levity_reversal=lr, + gravity_reversal=gr, + ) + + +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=7, + ).update(name="Not Doing") + + for number, _lvty, _grav, _rev in IMPLICIT: + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=number, + ).update( + levity_qualifier="", + gravity_qualifier="", + levity_reversal="", + gravity_reversal="", + ) + + for number, _le, _ge, _lr, _gr in EXPLICIT: + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=number, + ).update( + levity_emanation="", + gravity_emanation="", + levity_reversal="", + gravity_reversal="", + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0016_card49_bestowing_eagle"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/migrations/0018_add_italic_word.py b/src/apps/epic/migrations/0018_add_italic_word.py new file mode 100644 index 0000000..ebb301c --- /dev/null +++ b/src/apps/epic/migrations/0018_add_italic_word.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0 on 2026-05-01 03:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0017_castanedan_virtues'), + ] + + operations = [ + migrations.AddField( + model_name='tarotcard', + name='italic_word', + field=models.CharField(blank=True, default='', max_length=50), + ), + ] diff --git a/src/apps/epic/migrations/0019_explicit_virtues_italic_word.py b/src/apps/epic/migrations/0019_explicit_virtues_italic_word.py new file mode 100644 index 0000000..5727e90 --- /dev/null +++ b/src/apps/epic/migrations/0019_explicit_virtues_italic_word.py @@ -0,0 +1,51 @@ +"""Set TarotCard.italic_word for trumps 19-21 (Stalking / Dreaming / Intent). + +Each of these three Castanedan virtues has its title key-word italicized +across every emanation/reversal slot ("The Hunter's *Stalking*", "The +Sleeper's *Stalking*", etc.). Storing the word in a single field lets the +renderer wrap it in at display time without HTML in the data. +""" +from django.db import migrations + + +WORDS = { + 19: "Stalking", + 20: "Dreaming", + 21: "Intent", +} + + +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 + for number, word in WORDS.items(): + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR", number=number, + ).update(italic_word=word) + + +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__in=list(WORDS), + ).update(italic_word="") + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0018_add_italic_word"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=reverse), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index 2892506..ab789c5 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -257,6 +257,7 @@ class TarotCard(models.Model): gravity_emanation = models.CharField(max_length=200, blank=True, default='') levity_reversal = models.CharField(max_length=200, blank=True, default='') # polarity-split reversal (card 48) gravity_reversal = models.CharField(max_length=200, blank=True, default='') + italic_word = models.CharField(max_length=50, blank=True, default='') # word(s) inside any title slot to wrap in at render time (e.g. "Stalking" for trumps 19-21) energies = models.JSONField(default=list) # list of {type, effect} dicts — Energy interactions operations = models.JSONField(default=list) # list of {type, effect} dicts — Operation interactions keywords_upright = models.JSONField(default=list) diff --git a/src/apps/epic/static/apps/epic/stage-card.js b/src/apps/epic/static/apps/epic/stage-card.js index 37c5f38..b113737 100644 --- a/src/apps/epic/static/apps/epic/stage-card.js +++ b/src/apps/epic/static/apps/epic/stage-card.js @@ -38,9 +38,44 @@ var StageCard = (function () { gravity_emanation: el.dataset.gravityEmanation || '', levity_reversal: el.dataset.levityReversal || '', gravity_reversal: el.dataset.gravityReversal || '', + // Word(s) inside any title slot to wrap in at render time + // (e.g. "Stalking" for trumps 19-21). Blank for most cards. + italic_word: el.dataset.italicWord || '', }; } + function _escape(s) { + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + // Wrap every occurrence of `word` in `text` with ..., returning + // an HTML-safe string. Both inputs are escaped before splicing. + function _italicize(text, word) { + if (!text) return ''; + var safeText = _escape(text); + if (!word) return safeText; + var safeWord = _escape(word); + if (safeText.indexOf(safeWord) === -1) return safeText; + return safeText.split(safeWord).join('' + safeWord + ''); + } + + // Set element text — uses innerHTML if the card has an italic_word that + // appears in the string, otherwise textContent. Caller passes the full + // card so we can pick up `italic_word` without re-passing it everywhere. + function _setTitle(el, text, card) { + if (!el) return; + if (card.italic_word && text && text.indexOf(card.italic_word) !== -1) { + el.innerHTML = _italicize(text, card.italic_word); + } else { + el.textContent = text || ''; + } + } + // Decide whether a card object represents Major Arcana — sig sources from // `data-arcana` (Django's `get_arcana_display`, e.g. "Major Arcana"), sea // from card.arcana (model code, e.g. "MAJOR"). Accept both. @@ -89,12 +124,12 @@ var StageCard = (function () { var qBelow = stageCard.querySelector('.sig-qualifier-below'); if (emanationOverride) { - // Cards 48-49 — single-line title, no qualifier slots. - if (nameEl) nameEl.textContent = emanationOverride; + // Cards 48-49 + trumps 19-21 — single-line title, no qualifier slots. + _setTitle(nameEl, emanationOverride, card); if (qAbove) qAbove.textContent = ''; if (qBelow) qBelow.textContent = ''; } else { - if (nameEl) nameEl.textContent = isMajor ? title + ',' : title; + _setTitle(nameEl, isMajor ? title + ',' : title, card); if (qAbove) qAbove.textContent = isMajor ? '' : qualifier; if (qBelow) qBelow.textContent = isMajor ? qualifier : ''; } @@ -108,17 +143,17 @@ var StageCard = (function () { var rName = stageCard.querySelector('.fan-card-reversal-name'); if (rQual && rName) { if (reversalOverride) { - rQual.textContent = reversalOverride; + _setTitle(rQual, reversalOverride, card); rName.textContent = ''; } else if (isMajor) { - rQual.textContent = title + ','; + _setTitle(rQual, title + ',', card); rName.textContent = qualifier; } else if (reversalQualifier) { rQual.textContent = reversalQualifier; - rName.textContent = title; + _setTitle(rName, title, card); } else { rQual.textContent = qualifier; - rName.textContent = title; + _setTitle(rName, title, card); } } } diff --git a/src/apps/epic/templatetags/__init__.py b/src/apps/epic/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/apps/epic/templatetags/tarot_filters.py b/src/apps/epic/templatetags/tarot_filters.py new file mode 100644 index 0000000..a077b15 --- /dev/null +++ b/src/apps/epic/templatetags/tarot_filters.py @@ -0,0 +1,24 @@ +"""Template filters for tarot card rendering.""" +from django import template +from django.utils.html import escape +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.filter(name='italicize') +def italicize(text, word): + """Wrap every occurrence of `word` in `text` with .... + + Both `text` and `word` are escape()d before splicing the in, so the + output is safe to mark `mark_safe` regardless of input. + """ + if not text or not word: + return text + text = str(text) + word = str(word) + if word not in text: + return text + safe_text = escape(text) + safe_word = escape(word) + return mark_safe(safe_text.replace(safe_word, '{}'.format(safe_word))) diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index 2e5ad5b..94e4e23 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -1146,6 +1146,13 @@ def sea_deck(request, room_id): 'levity_qualifier': c.levity_qualifier, 'gravity_qualifier': c.gravity_qualifier, 'reversal_qualifier': c.reversal_qualifier, + # Polarity-split full-title overrides (cards 48-49 + trumps 19-21) + 'levity_emanation': c.levity_emanation, + 'gravity_emanation': c.gravity_emanation, + 'levity_reversal': c.levity_reversal, + 'gravity_reversal': c.gravity_reversal, + # Word inside any title slot to wrap in at render time + 'italic_word': c.italic_word, 'keywords_upright': c.keywords_upright, 'keywords_reversed': c.keywords_reversed, 'energies': c.energies, diff --git a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html index 929c439..6c97915 100644 --- a/src/templates/apps/gameboard/_partials/_sig_select_overlay.html +++ b/src/templates/apps/gameboard/_partials/_sig_select_overlay.html @@ -81,7 +81,12 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_ data-operations="{{ card.operations_json }}" data-levity-qualifier="{{ card.levity_qualifier }}" data-gravity-qualifier="{{ card.gravity_qualifier }}" - data-reversal-qualifier="{{ card.reversal_qualifier }}"> + data-reversal-qualifier="{{ card.reversal_qualifier }}" + data-levity-emanation="{{ card.levity_emanation }}" + data-gravity-emanation="{{ card.gravity_emanation }}" + data-levity-reversal="{{ card.levity_reversal }}" + data-gravity-reversal="{{ card.gravity_reversal }}" + data-italic-word="{{ card.italic_word }}">
{{ card.corner_rank }} {% if card.suit_icon %}{% endif %} diff --git a/src/templates/apps/gameboard/_partials/_tarot_fan.html b/src/templates/apps/gameboard/_partials/_tarot_fan.html index ec462e0..a50969e 100644 --- a/src/templates/apps/gameboard/_partials/_tarot_fan.html +++ b/src/templates/apps/gameboard/_partials/_tarot_fan.html @@ -1,3 +1,4 @@ +{% load tarot_filters %} {% for card in cards %}
+ data-gravity-reversal="{{ card.gravity_reversal }}" + data-italic-word="{{ card.italic_word }}">
{{ card.corner_rank }} {% if card.suit_icon %}{% endif %} @@ -25,14 +27,14 @@
{% if card.levity_emanation %} - {# Polarity-split title (cards 48-49); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #} -

{{ card.levity_emanation }}

+ {# Polarity-split title (cards 48-49 + trumps 19-21); no qualifier slots — qualifier is baked into the title between "The" and the proper noun #} +

{{ card.levity_emanation|italicize:card.italic_word }}

{% else %} {% if card.name_group %}

{{ card.name_group }}

{% endif %} {% if card.arcana != "MAJOR" and card.levity_qualifier %}

{{ card.levity_qualifier }}

{% endif %} -

{{ card.name_title }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}

+

{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}

{% if card.arcana == "MAJOR" and card.levity_qualifier %}

{{ card.levity_qualifier }}

{% endif %} @@ -47,12 +49,12 @@ {% if card.levity_reversal %} {# Polarity-split reversal title — single line, qualifier slot empty. Title goes in the qualifier slot so it visually lands on top after spin. #}

-

{{ card.levity_reversal }}

+

{{ card.levity_reversal|italicize:card.italic_word }}

{% elif card.arcana == "MAJOR" %}

{{ card.levity_qualifier|default:card.gravity_qualifier }}

-

{{ card.name_title }}{% if card.levity_qualifier %},{% endif %}

+

{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}

{% else %} -

{{ card.name_title }}

+

{{ card.name_title|italicize:card.italic_word }}

{{ card.reversal_qualifier|default:card.gravity_qualifier }}

{% endif %}