Compare commits

...

3 Commits

Author SHA1 Message Date
Disco DeDisco
8b0ad545c9 collapse epic migrations 0007–0022 → 0007_finalize_earthman_deck; add reset_staging_db
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- 16 incremental Earthman tweak migrations folded into one end-state finalize
  migration (rename mechanisms→energies / articulations→operations /
  reversal→reversal_qualifier; +italic_word; suit court reversals; Schizo
  energies+operations; card 49 polarity reversal titles; Castanedan Virtues
  trumps 6–9 + 19–21; trump 8 U+2011 hyphen; trump 9 U+00A0 nbsp; pips → MINOR)
- 22 epic migrations → 7; 748 ITs green
- new mgmt cmd `reset_staging_db` — drops schema (Postgres) / tables (sqlite)
  & re-runs migrate; refuses on prod hosts; needs `--i-mean-it` when DEBUG=False;
  interactive host-name confirmation locally; calls ensure_superuser after

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:22:43 -04:00
Disco DeDisco
3410f073f0 fan-card title symmetry; pips → Minor; tray Sig card
- title slot: <h3> → <p>; font-size 0.1 → 0.087 (deck) / 0.093 → 0.08 (sig/sea); text-wrap: balance — kills upright/reversal asymmetry & all per-card squeeze hacks
- trump 8 hyphen → U+2011, trump 9 space → U+00A0 (mig 0021) so titles wrap as intended
- pips (Earthman 1–10) → MINOR arcana (mig 0022); StageCard._arcanaDisplay() picks the right label
- PICK SEA: re-clicking a deposited slot now restores the server-rolled reversed state (sea.js _populate toggle)
- tray Sig card: render same .sig-stage-card.sea-sig-card (rank + icon, -5deg) as Sea center; --sig-card-w sized off --tray-cell-size
- title_squeeze_class kept as no-op for template compat
- 0020 (Self-Unimportance rename) included from prior turn

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 02:06:55 -04:00
Disco DeDisco
c264b6e3ee PICK SEA reversal axis: server-side roll + preview + deposited slot — TDD
- new apps/epic/utils.STACK_REVERSAL_PROBABILITY (=0.25) + stack_reversal_probability(user, room) helper; single source of truth across game phases & one-line swap point for forthcoming per-user-profile config
- sea_deck view rolls each card's `reversed` axis at fetch time using the helper, attaches to card JSON; matches the eager shuffle pattern (whole deal determined at phase start)
- room_view + sea_partial pass `stack_reversal_pct` into context for the new <p class="sea-reversal-hint">25% reversals</p> hint above the SPREAD combobox (italic, 0.7rem, 0.55 opacity)
- SeaDeal.openStage applies .stage-card--reversed + .is-reversed to stat block when card.reversed → preview lands face-reversed w. REVERSAL keywords
- _fillSlot adds .sea-card-slot--reversed → slot itself rotates 180° (bg + border + content stack flips, not just inner chars upside-down in place); .sea-pos-cross overrides to 270° to compose w. its existing 90°
- _fillSlot adds .sea-card-slot--rank-long when corner_rank.length ≥ 5 (XVIII / XXIII / XXVIII / XXXIII / XXXVIII / XLIII / XLVIII) → SCSS scaleX(0.7) + letter-spacing -0.05em squeezes horizontally w.o changing font-size

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 00:11:40 -04:00
29 changed files with 480 additions and 671 deletions

View File

View File

@@ -0,0 +1,110 @@
"""Wipe the configured database and re-run all migrations from scratch.
Intended for ephemeral environments (staging) where losing every user, room,
billpost, token, etc. is acceptable. Refuses to run when DEBUG=False unless
the operator explicitly confirms with --i-mean-it, and always prints the
DB host before doing anything destructive.
Typical staging usage from the deploy host:
docker exec -it gamearray python manage.py reset_staging_db --i-mean-it
Locally (sqlite, DEBUG=True) the safety prompt is skipped:
python src/manage.py reset_staging_db
"""
from django.conf import settings
from django.core.management import call_command
from django.core.management.base import BaseCommand, CommandError
from django.db import connection
PROD_HOST_FRAGMENTS = ("earthmanrpg.me",)
class Command(BaseCommand):
help = "Drop every table in the default DB and re-run migrations. Destructive."
def add_arguments(self, parser):
parser.add_argument(
"--i-mean-it",
action="store_true",
help="Required when DEBUG=False. Bypasses the interactive confirmation.",
)
parser.add_argument(
"--no-superuser",
action="store_true",
help="Skip the post-migrate ensure_superuser call.",
)
def handle(self, *args, **opts):
db_settings = settings.DATABASES["default"]
engine = db_settings.get("ENGINE", "")
host = db_settings.get("HOST") or db_settings.get("NAME") or "(unknown)"
# Refuse outright if the host name suggests production
if any(frag in str(host) for frag in PROD_HOST_FRAGMENTS) and not host.startswith("staging"):
if "staging" not in str(host):
raise CommandError(
f"Refusing to reset DB at host={host!r} — looks like production. "
"Edit PROD_HOST_FRAGMENTS in this command if you really mean it."
)
self.stdout.write(self.style.WARNING(
f"\nAbout to wipe DB:\n ENGINE: {engine}\n HOST/NAME: {host}\n"
))
if not settings.DEBUG and not opts["i_mean_it"]:
raise CommandError(
"DEBUG=False — pass --i-mean-it to confirm. "
"(This is the staging-safety check; it does not bypass the prod-host refusal above.)"
)
if settings.DEBUG and not opts["i_mean_it"]:
answer = input("Type the DB host/name to confirm: ").strip()
if answer != str(host):
raise CommandError(f"Got {answer!r}, expected {str(host)!r}. Aborting.")
# Drop schema. Postgres + sqlite both honor `flush --no-input`'s
# truncate-tables-in-place model, but for a *fresh* migration run we
# need the migration history wiped too. For Postgres the cleanest
# route is `DROP SCHEMA public CASCADE; CREATE SCHEMA public;` —
# for sqlite, deleting the file is simpler but Django's connection
# has it open. So: introspect the connection and drop all tables.
self.stdout.write("Dropping all tables…")
self._drop_all_tables()
self.stdout.write("Running migrate from scratch…")
call_command("migrate", verbosity=1, interactive=False)
if not opts["no_superuser"]:
try:
call_command("ensure_superuser", verbosity=1)
except Exception as exc: # pragma: no cover - depends on env vars
self.stdout.write(self.style.WARNING(
f"ensure_superuser skipped/failed: {exc}"
))
self.stdout.write(self.style.SUCCESS("\nDB reset complete."))
def _drop_all_tables(self):
vendor = connection.vendor
with connection.cursor() as cursor:
if vendor == "postgresql":
cursor.execute("DROP SCHEMA public CASCADE;")
cursor.execute("CREATE SCHEMA public;")
cursor.execute("GRANT ALL ON SCHEMA public TO public;")
elif vendor == "sqlite":
cursor.execute("PRAGMA foreign_keys = OFF;")
cursor.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
tables = [row[0] for row in cursor.fetchall()]
for table in tables:
cursor.execute(f'DROP TABLE IF EXISTS "{table}"')
cursor.execute("PRAGMA foreign_keys = ON;")
else:
raise CommandError(
f"reset_staging_db only knows postgresql + sqlite, got {vendor!r}"
)

View File

@@ -0,0 +1,200 @@
"""Collapse of old migrations 00070022 into a single end-state finalize.
Schema:
- mechanisms → energies (was 0008)
- articulations → operations (was 0008)
- reversal → reversal_qualifier (was 0014)
- +italic_word (was 0018)
Data (operates on the Earthman deck seeded in 0004; idempotent against the
current schema, which is the post-rename state thanks to the operations
above running first):
- Middle court reversal_qualifier per suit (was 0007)
- Schizo energies + operations w/ .card-ref (was 0008 + 0009)
- fa-hand-dots fallback for empty MAJOR icons (was 0010; only card 41
in fresh-seed state)
- Card 49 polarity-split reversal titles (was 0015 + 0016)
- Castanedan Virtues: trumps 6-9 + 19-21 (was 0017)
- italic_word for trumps 19-21 (was 0019)
- Trump 8 rename + non-breaking hyphen (was 0020 + 0021)
- Trump 9 non-breaking space (was 0021)
- Pip cards (number 1-10) MIDDLE → MINOR arcana (was 0022)
Skipped (all no-ops against fresh 0004 seed):
- 0011 (nomad/schizo icons already correct in 0004)
- 0012 (no PENTACLES seeded for Earthman in 0004)
- 0013 (nomad icon already fa-hat-cowboy-side in 0004)
"""
from django.db import migrations, models
# ── Schizo energies + operations ─────────────────────────────────────────────
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
# ── Middle court suit reversals ──────────────────────────────────────────────
SUIT_REVERSAL_QUALIFIER = {
"BRANDS": "Seething",
"GRAILS": "Gloomy",
"BLADES": "Nervous",
"CROWNS": "Vacant",
}
COURT_NUMBERS = [11, 12, 13, 14]
# ── Castanedan Virtues ───────────────────────────────────────────────────────
IMPLICIT_VIRTUES = [
# (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_VIRTUES = [
# (number, levity_emanation, gravity_emanation, levity_reversal, gravity_reversal, italic_word)
(19, "The Hunter's Stalking", "The Hunter's Stalking", "The Sleeper's Stalking", "The Quarry's Stalking", "Stalking"),
(20, "The Dreamer's Dreaming", "The Dreamer's Dreaming", "The Sleeper's Dreaming", "The Dreamed's Dreaming", "Dreaming"),
(21, "The Warrior's Intent", "The Warrior's Intent", "The Sleeper's Intent", "The Predator's Intent", "Intent"),
]
def finalize(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
# Middle court suit reversals
for suit, qualifier in SUIT_REVERSAL_QUALIFIER.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", suit=suit,
number__in=COURT_NUMBERS,
).update(reversal_qualifier=qualifier)
# Schizo: clear stray reversal_qualifier (0004 seeds 'Territoriality') +
# populate energies/operations
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1,
).update(
reversal_qualifier="",
energies=SCHIZO_ENERGIES,
operations=SCHIZO_OPERATIONS,
)
# fa-hand-dots fallback for empty MAJOR icons (number ≥ 2)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number__gte=2, icon="",
).update(icon="fa-hand-dots")
# Card 49 polarity reversal titles
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=49,
).update(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The Bestowing Eagle",
)
# Castanedan Virtues — implicit (trumps 6-9): trump 7 name canonicalize +
# qualifiers + reversal titles
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not-Doing")
for number, lvty, grav, rev in IMPLICIT_VIRTUES:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_qualifier=lvty,
gravity_qualifier=grav,
levity_reversal=rev,
gravity_reversal=rev,
)
# Castanedan Virtues — explicit (trumps 19-21): polarity-split titles +
# italic_word for the agency stem
for number, le, ge, lr, gr, word in EXPLICIT_VIRTUES:
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number,
).update(
levity_emanation=le,
gravity_emanation=ge,
levity_reversal=lr,
gravity_reversal=gr,
italic_word=word,
)
# Trump 8: "Losing Self-Importance" → "SelfUnimportance" w/ U+2011
# non-breaking hyphen (keeps title on one line above qualifier)
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="SelfUnimportance", slug="self-unimportance")
# Trump 9: insert U+00A0 between "Personal" and "History" so they wrap as
# a single unit ("Erasing / Personal History, / Sublimating")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name="Erasing Personal History")
# Pip cards (number 1-10) → MINOR arcana; courts (11-14) stay MIDDLE
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__lte=10,
).update(arcana="MINOR")
def revert(apps, schema_editor):
"""Reverse just enough to restore 0006 schema state. Data reverts to the
raw 0004 seed shape (without any of the post-seed tweaks)."""
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Pip arcana
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__lte=10,
).update(arcana="MIDDLE")
# Trump 8 + 9 names back
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=8,
).update(name="Losing Self-Importance", slug="losing-self-importance")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=9,
).update(name="Erasing Personal History")
# Trump 7 name back
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=7,
).update(name="Not Doing")
class Migration(migrations.Migration):
dependencies = [
("epic", "0006_add_deck_variant_to_tableseat"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RenameField("TarotCard", "reversal", "reversal_qualifier"),
migrations.AddField(
model_name="tarotcard",
name="italic_word",
field=models.CharField(blank=True, default="", max_length=50),
),
migrations.RunPython(finalize, reverse_code=revert),
]

View File

@@ -1,73 +0,0 @@
"""Populate TarotCard.reversal for Earthman Middle Arcana court cards.
Each suit has a fixed reversal qualifier that replaces the polarity qualifier
(Elevated/Graven) when the card is spun to its reversed face:
Brands → Seething Grails → Gloomy Blades → Nervous Crowns → Vacant
Also clears the incorrectly inherited reversal on The Schizo (card 1), which
mistakenly carried 'Territoriality' from The Occultist (card 2).
"""
from django.db import migrations
SUIT_REVERSAL_QUALIFIER = {
"BRANDS": "Seething",
"GRAILS": "Gloomy",
"BLADES": "Nervous",
"CROWNS": "Vacant",
}
RANK_NAMES = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
def populate_reversals(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
# Middle Arcana court cards
for suit, qualifier in SUIT_REVERSAL_QUALIFIER.items():
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MIDDLE",
suit=suit,
number__in=list(RANK_NAMES.keys()),
).update(reversal=qualifier)
# Clear The Schizo's incorrectly inherited reversal (belongs to The Occultist)
TarotCard.objects.filter(
deck_variant=earthman,
arcana="MAJOR",
number=1,
).update(reversal="")
def clear_reversals(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="MIDDLE",
suit__in=list(SUIT_REVERSAL_QUALIFIER.keys()),
number__in=list(RANK_NAMES.keys()),
).update(reversal="")
TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR", number=1).update(
reversal="Territoriality"
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0006_add_deck_variant_to_tableseat"),
]
operations = [
migrations.RunPython(populate_reversals, reverse_code=clear_reversals),
]

View File

@@ -1,57 +0,0 @@
"""Rename mechanisms→energies and articulations→operations on TarotCard;
seed The Schizo (Earthman major arcana card 1) with Energy and Operation entries.
"""
from django.db import migrations
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
def seed_schizo(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(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(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(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0007_populate_middle_arcana_reversals"),
]
operations = [
migrations.RenameField("TarotCard", "mechanisms", "energies"),
migrations.RenameField("TarotCard", "articulations", "operations"),
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -1,53 +0,0 @@
"""Re-seed The Schizo's energies and operations with .card-ref HTML spans."""
from django.db import migrations
CR = '<span class="card-ref">{}</span>'
SCHIZO_ENERGIES = [
{"type": "LIBIDO", "effect": f'When encountering territorial Libido, may convert Emanation into {CR.format("1. The Priest")}.'},
{"type": "NUMEN", "effect": f'When encountering despotic Numen, may convert Emanation into {CR.format("1. The Powerful")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering axiomatic Voluptas, may convert Emanation into {CR.format("1. The Normal")}.'},
{"type": "VOLUPTAS", "effect": f'When encountering annihilating Voluptas, may convert Emanation into {CR.format("1. The Surrendered")}.'},
]
SCHIZO_OPERATIONS = [
{"type": "COVER", "effect": f'When covering {CR.format("2. The Occultist")} she may choose, by converting her own Reversal into {CR.format("2. Pestilence")}, to convert this Reversal into {CR.format("1. The Pervert")}.'},
{"type": "CROWN", "effect": f'When crowning {CR.format("3. The Despot")} she may choose, by converting her own Reversal into {CR.format("3. War")}, to convert this Reversal into {CR.format("1. The Paranoiac")}.'},
{"type": "BEHIND", "effect": f'When behind {CR.format("4. The Capitalist")} he may choose, by converting his own Reversal into {CR.format("4. Famine")}, to convert this Reversal into {CR.format("1. The Neurotic")}.'},
{"type": "BEFORE", "effect": f'When before {CR.format("5. The Fascist")} he may choose, by converting his own Reversal into {CR.format("5. Death")}, to convert this Reversal into {CR.format("1. The Suicidal")}.'},
]
def seed_schizo(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(energies=SCHIZO_ENERGIES, operations=SCHIZO_OPERATIONS)
def clear_schizo(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(energies=[], operations=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0008_rename_energies_operations_seed_schizo"),
]
operations = [
migrations.RunPython(seed_schizo, reverse_code=clear_schizo),
]

View File

@@ -1,49 +0,0 @@
"""Assign fa-hand-dots icon to all Earthman Major Arcana cards with number >= 2.
Cards 0 (The Nomad) and 1 (The Schizo) keep their existing icon value so they
can receive distinct icons later. All other Major Arcana groups (Popes, Implicit
Virtues, Elements, Realms, Explicit Virtues, Zodiac, Lunars, Planets, Inner Rings,
polarity-split finals) default to fa-hand-dots until per-group icons are assigned.
"""
from django.db import migrations
def assign_hand_dots(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__gte=2,
icon="",
).update(icon="fa-hand-dots")
def clear_hand_dots(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__gte=2,
icon="fa-hand-dots",
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0009_schizo_card_ref_spans"),
]
operations = [
migrations.RunPython(assign_hand_dots, reverse_code=clear_hand_dots),
]

View File

@@ -1,43 +0,0 @@
"""Assign individual icons to The Nomad (0) and The Schizo (1).
All other Major Arcana already have fa-hand-dots from migration 0010.
"""
from django.db import migrations
ICONS = {0: 'fa-hat-cowboy-side', 1: 'fa-hat-wizard'}
def assign_icons(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, icon in ICONS.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(icon=icon)
def clear_icons(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(ICONS.keys())
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0010_major_arcana_hand_dots_icon"),
]
operations = [
migrations.RunPython(assign_icons, reverse_code=clear_icons),
]

View File

@@ -1,27 +0,0 @@
"""Delete 4 stray PENTACLES court cards from the Earthman deck.
These survived the migration collapse; the Earthman deck uses
BRANDS/GRAILS/BLADES/CROWNS only.
"""
from django.db import migrations
def delete_pentacles(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, suit="PENTACLES").delete()
class Migration(migrations.Migration):
dependencies = [
("epic", "0011_nomad_schizo_icons"),
]
operations = [
migrations.RunPython(delete_pentacles, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1,25 +0,0 @@
"""Fix The Nomad icon: fa-hat-cowboy → fa-hat-cowboy-side."""
from django.db import migrations
def fix_nomad_icon(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, icon="fa-hat-cowboy"
).update(icon="fa-hat-cowboy-side")
class Migration(migrations.Migration):
dependencies = [
("epic", "0012_delete_stray_pentacles"),
]
operations = [
migrations.RunPython(fix_nomad_icon, reverse_code=migrations.RunPython.noop),
]

View File

@@ -1,22 +0,0 @@
"""Rename TarotCard.reversal → TarotCard.reversal_qualifier.
Symmetric naming with levity_qualifier / gravity_qualifier; disambiguates the
qualifier-text field from the reversal *axis* state and the keywords_reversed
list. Pure column rename — no data movement.
"""
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("epic", "0013_fix_nomad_icon"),
]
operations = [
migrations.RenameField(
model_name="tarotcard",
old_name="reversal",
new_name="reversal_qualifier",
),
]

View File

@@ -1,60 +0,0 @@
"""Populate card 49's polarity-split reversal titles.
The Earthman deck's last two cards (4849) carry distinct titles per polarity
(stored in `levity_emanation` / `gravity_emanation` / `levity_reversal` /
`gravity_reversal`) rather than a shared title + qualifier.
Card 48 had its full set seeded in migration 0004:
levity: Father Sky → reversal: The Storm
gravity: Mother Sea → reversal: The Flood
Card 49 had only emanations seeded; this migration fills the reversals:
levity: The Effulgent Mould of Man → reversal: The Vibrational Mould of Man
gravity: The Devouring Eagle → reversal: The All-Bestowing Eagle
The "qualifier" (Effulgent / Vibrational / Devouring / All-Bestowing) is baked
into the title between "The" and the title-proper rather than rendered as a
separate qualifier slot — the per-polarity title strings are stored verbatim.
"""
from django.db import migrations
def populate_card49_reversals(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(
levity_reversal="The Vibrational Mould of Man",
gravity_reversal="The All-Bestowing Eagle",
)
def clear_card49_reversals(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(
levity_reversal="",
gravity_reversal="",
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0014_rename_reversal_to_reversal_qualifier"),
]
operations = [
migrations.RunPython(populate_card49_reversals, reverse_code=clear_card49_reversals),
]

View File

@@ -1,37 +0,0 @@
"""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),
]

View File

@@ -1,114 +0,0 @@
"""Populate the seven Castanedan Virtues — trumps 69 (Implicit) + 1921 (Explicit).
Implicit Virtues (69): 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 (1921): 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),
]

View File

@@ -1,18 +0,0 @@
# 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),
),
]

View File

@@ -1,51 +0,0 @@
"""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 <em> 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),
]

View File

@@ -211,8 +211,8 @@ class DeckVariant(models.Model):
class TarotCard(models.Model):
MAJOR = "MAJOR"
MINOR = "MINOR"
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
MINOR = "MINOR" # pip cards (numbers 1-10)
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K, numbers 11-14)
ARCANA_CHOICES = [
(MAJOR, "Major Arcana"),
(MINOR, "Minor Arcana"),
@@ -323,6 +323,14 @@ class TarotCard(models.Model):
return self.name.split(': ', 1)[1]
return self.name
@property
def title_squeeze_class(self):
"""No-op kept for template compatibility. Title fit is now handled by
a smaller base `font-size` on `.fan-card-name`/`.fan-card-reversal-*`
plus `text-wrap: balance` (see `_card-deck.scss`) — every long-title
card fits naturally without per-card CSS hacks."""
return ''
@property
def suit_icon(self):
if self.icon:

View File

@@ -25,9 +25,12 @@ var SeaDeal = (function () {
_infoData = StageCard.buildInfoData(card);
_infoIdx = 0;
// Reset SPIN
stageCard.classList.remove('stage-card--reversed');
statBlock.classList.remove('is-reversed');
// Sync SPIN state to the card's reversal axis — `card.reversed` is set
// server-side at deck-fetch time (apps.epic.utils.stack_reversal_probability)
// and persisted in `_seaHand`, so re-clicking a deposited slot must
// restore that state, not reset to upright.
stageCard.classList.toggle('stage-card--reversed', !!card.reversed);
statBlock.classList.toggle('is-reversed', !!card.reversed);
_closeInfo();
}
@@ -64,6 +67,10 @@ var SeaDeal = (function () {
slot.classList.remove('sea-card-slot--empty');
slot.classList.add('sea-card-slot--filled');
slot.classList.add(isLevity ? 'sea-card-slot--levity' : 'sea-card-slot--gravity');
if (card.reversed) slot.classList.add('sea-card-slot--reversed');
// Long Roman numerals (XVIII / XXIII / XXVIII / XXXIII / XXXVIII /
// XLIII / XLVIII) need horizontal squeezing to fit the slot — see SCSS.
if ((card.corner_rank || '').length >= 5) slot.classList.add('sea-card-slot--rank-long');
slot.dataset.cardId = String(card.id);
slot.dataset.posKey = posSelector;
slot.innerHTML =

View File

@@ -84,6 +84,15 @@ var StageCard = (function () {
return a === 'MAJOR' || a === 'MAJOR ARCANA';
}
// Map either form (model code or display) to the rendered label.
function _arcanaDisplay(card) {
var a = (card.arcana || '').toUpperCase();
if (a === 'MAJOR' || a === 'MAJOR ARCANA') return 'Major Arcana';
if (a === 'MINOR' || a === 'MINOR ARCANA') return 'Minor Arcana';
if (a === 'MIDDLE' || a === 'MIDDLE ARCANA') return 'Middle Arcana';
return '';
}
// Paint the stage-card's upright + reversal faces from a normalized card
// object + the active polarity ('levity' | 'gravity'). Reversal-qualifier
// falls back to the current polarity's qualifier when blank (6F behavior).
@@ -117,7 +126,7 @@ var StageCard = (function () {
if (nameGroupEl) nameGroupEl.textContent = emanationOverride ? '' : (card.name_group || '');
var arcanaEl = stageCard.querySelector('.fan-card-arcana');
if (arcanaEl) arcanaEl.textContent = isMajor ? 'Major Arcana' : 'Middle Arcana';
if (arcanaEl) arcanaEl.textContent = _arcanaDisplay(card);
var nameEl = stageCard.querySelector('.fan-card-name');
var qAbove = stageCard.querySelector('.sig-qualifier-above');

View File

@@ -3,6 +3,28 @@ from django.db.models import Q
from apps.epic.models import Room, RoomInvite
# ── Game-wide constants ────────────────────────────────────────────────────
# Reversal probability applied to any card pulled from a stack, anywhere in
# the game (PICK SEA initially; future phases — gameplay draws etc. — will
# share this single source of truth). Stub for a future per-user profile
# override: callers MUST go through stack_reversal_probability(user, room)
# rather than referencing the constant directly so the user-config hookup is
# a one-line change inside the helper.
STACK_REVERSAL_PROBABILITY = 0.25
def stack_reversal_probability(user=None, room=None):
"""Reversal probability for a draw stack in this user's context.
Current behavior: returns the module default for everyone. Plumbing point
for a forthcoming per-user setting — when that lands, swap the body to
something like `return getattr(user.profile, 'reversal_rate', STACK_REVERSAL_PROBABILITY)`
and every call site picks up the per-user value automatically.
"""
return STACK_REVERSAL_PROBABILITY
def _planet_house(degree, cusps):
"""Return 1-based house number for a planet at ecliptic degree.

View File

@@ -22,7 +22,7 @@ from apps.epic.models import (
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
select_token, sig_deck_cards,
)
from apps.epic.utils import _compute_distinctions, _planet_house
from apps.epic.utils import _compute_distinctions, _planet_house, stack_reversal_probability
from apps.lyric.models import Token
@@ -402,6 +402,9 @@ def room_view(request, room_id):
ctx = _role_select_context(room, request.user)
ctx["room"] = room
ctx["page_class"] = "page-gameboard"
# Reversal-rate hint label under PICK SEA's SPREAD select — same helper as
# sea_partial so the value tracks any future per-user override automatically.
ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100))
return render(request, "apps/gameboard/room.html", ctx)
@@ -1132,6 +1135,11 @@ def sea_deck(request, room_id):
.values_list('significator_id', flat=True)
)
# Roll reversal eagerly during the shuffle — the deck order is fully
# determined at phase start, so the reversal axis should be too. Future
# per-user-profile config rides this same helper.
reversal_prob = stack_reversal_probability(request.user, room)
def _card_dict(c):
return {
'id': c.id,
@@ -1157,6 +1165,8 @@ def sea_deck(request, room_id):
'keywords_reversed': c.keywords_reversed,
'energies': c.energies,
'operations': c.operations,
# Pre-rolled reversal axis — server-deterministic, client just reads
'reversed': _random.random() < reversal_prob,
}
available = list(
@@ -1178,5 +1188,10 @@ def sea_partial(request, room_id):
if not ctx.get('sky_confirmed'):
return HttpResponse(status=403)
ctx['room'] = room
# Reversal-rate hint label under SPREAD — both the percentage AND the raw
# probability flow from the same helper, so when per-user config lands we
# only swap the helper body and every render picks it up.
_prob = stack_reversal_probability(request.user, room)
ctx['stack_reversal_pct'] = int(round(_prob * 100))
return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)

View File

@@ -33,14 +33,14 @@ function initGameKitTooltips() {
if (portal.classList.contains('active') && activeToken) {
const tokenRect = activeToken.getBoundingClientRect();
const portalRect = portal.getBoundingClientRect();
// Expand left to cover button overflow outside portal edge
const expandedPortalRect = {
left: portalRect.left - 24,
top: portalRect.top,
right: portalRect.right,
bottom: portalRect.bottom,
};
const rects = [tokenRect, expandedPortalRect];
const rects = [tokenRect, portalRect];
// Include the DON/DOFF button group's actual bounding rect so the
// portions of those buttons that hang past the portal's left edge
// (and above its top edge) stay inside the hover-tolerance region.
// Was previously a hardcoded 24px left expansion which didn't
// cover top overhang and underestimated wider button labels.
const equipBtns = portal.querySelector('.tt-equip-btns');
if (equipBtns) rects.push(equipBtns.getBoundingClientRect());
if (miniPortal.classList.contains('active')) rects.push(miniPortal.getBoundingClientRect());
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
@@ -244,7 +244,19 @@ function initGameKitTooltips() {
const tokenRect = token.getBoundingClientRect();
const halfW = portal.offsetWidth / 2;
const rawLeft = tokenRect.left + tokenRect.width / 2;
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
// Extra left clearance — the DON/DOFF button group is absolute-
// positioned with `left: -1rem` inside the portal and spills further
// left by its own width. Measure the actual overhang so the clamp
// keeps the buttons inside the viewport rather than just the portal.
let leftOverhang = 0;
const equipBtns = portal.querySelector('.tt-equip-btns');
if (equipBtns) {
const portalRect = portal.getBoundingClientRect();
const btnsRect = equipBtns.getBoundingClientRect();
leftOverhang = Math.max(0, portalRect.left - btnsRect.left);
}
const minLeft = halfW + 8 + leftOverhang;
const clampedLeft = Math.max(minLeft, Math.min(rawLeft, window.innerWidth - halfW - 8));
portal.style.left = Math.round(clampedLeft) + 'px';
// Show above when token is in lower viewport half; below when in upper half

View File

@@ -350,21 +350,34 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: calc(var(--fan-card-w) * 0.007);
// Ghost-line: reserve at least two title-line-heights of vertical space
// on each face so emanation + reversal stay symmetric even when one
// side has a single-line title (e.g. trumps 69 reversal "Indulged
// Folly" vs upright "Losing Self-Importance, / Sublimating").
min-height: calc(var(--fan-card-w) * 0.21);
}
// Qualifier shares the name's typography — same line, different content.
// Sizes scale with --fan-card-w so they stay proportional on mobile.
// `text-wrap: balance` distributes lines evenly so a borderline-long title
// breaks at the natural midpoint instead of greedy first-fit (e.g. trump
// 9 wraps as "Erasing / Personal History," instead of "Erasing Personal /
// History,"). Base size lowered from 0.1 → 0.087 (~13%) so all the long
// titles (trumps 8/9/18/36/41 + Queen of Crowns) fit without per-card
// hacks and without asymmetry between upright (h3) and reversal (p).
.sig-qualifier-above,
.sig-qualifier-below,
.fan-card-reversal-qualifier,
.fan-card-reversal-name,
.fan-card-name {
font-size: calc(var(--fan-card-w) * 0.1);
font-size: calc(var(--fan-card-w) * 0.087);
font-weight: bold;
margin: 0;
color: rgba(var(--terUser), 1);
transition: opacity 0.2s;
text-wrap: balance;
}
// Reversal-face spans pre-rotated so they read forward once the card spins
@@ -547,12 +560,14 @@ html:has(.sig-backdrop) {
.fan-card-face-upright { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; }
.fan-card-face-reversal { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; padding-top: 0.1rem; }
.fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; }
// Upright qualifier + name share sizing/weight/color with their reversed counterparts
// Upright qualifier + name share sizing/weight/color with their reversed counterparts.
// text-wrap: balance distributes lines evenly so longer titles wrap symmetrically;
// base size 0.08 (was 0.093) gives long titles room to fit without per-card hacks.
.sig-qualifier-above,
.sig-qualifier-below,
.fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
.fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 120px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-name,
.fan-card-reversal-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
.fan-card-reversal-name { font-size: calc(var(--sig-card-w, 120px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
.fan-card-correspondence{ display: none; } // Minchiate equivalence shown in game-kit only
// Reversed face elements — pre-rotated so they read forward after card spins
@@ -1053,6 +1068,28 @@ $sea-card-h: 6.5rem;
border-color: rgba(var(--secUser), 0.6);
}
// Reversed — pre-rolled by sea_deck server-side. Rotate the whole slot
// (background + border + content) so the rank/icon stacking order also
// flips (rank-top + icon-bottom upright → icon-top + rank-bottom reversed),
// not just each character upside-down in place.
.sea-card-slot--reversed { transform: rotate(180deg); }
// Cross-position adds 90° already; reversed cross combines to 270°. Higher
// specificity than the .sea-pos-cross .sea-card-slot rule so it wins.
.sea-pos-cross .sea-card-slot--reversed { transform: rotate(270deg); }
// Long Roman numerals (≥ 5 chars: XVIII, XXIII, XXVIII, XXXIII, XXXVIII,
// XLIII, XLVIII) — squeeze horizontally via scaleX so they fit the slot
// without dropping font-size (height stays the same). Class added in
// _fillSlot when card.corner_rank.length >= 5. Slot-level reversed rotation
// already carries the rank along, so scaleX is the only inner transform
// regardless of reversal state.
.sea-card-slot--rank-long .fan-corner-rank {
display: inline-block;
transform: scaleX(0.7);
letter-spacing: -0.05em;
}
// Deposited — fully opaque by default; Cover/Cross are semi-transparent
.sea-card-slot--visible { opacity: 1; transition: opacity 1s ease, box-shadow 0.15s ease; }
@@ -1126,8 +1163,9 @@ $sea-card-h: 6.5rem;
}
// .sig-stage-card is normally scoped inside .sig-stage — re-apply the card shell
// here so it renders correctly outside that context.
.sea-cross .sig-stage-card {
// here so it renders correctly outside that context. Class-based selector so it
// also applies in the tray (.tray-sig-card .sig-stage-card.sea-sig-card).
.sig-stage-card.sea-sig-card {
flex-shrink: 0;
width: var(--sig-card-w, #{$sea-card-w});
height: auto;
@@ -1188,6 +1226,16 @@ $sea-card-h: 6.5rem;
label { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; }
}
// Forthcoming-feature hint between SPREAD label and the combobox; rendered
// value comes from apps.epic.utils.stack_reversal_probability via the view
// context.
.sea-reversal-hint {
font-size: 0.7rem;
opacity: 0.55;
margin: -0.1rem 0 0;
font-style: italic;
}
// Custom combobox replacement for native <select>. See combobox.js for the
// expected markup; SCSS owns all visuals because the OS-native dropdown ignored
// option background/color anyway.
@@ -1466,9 +1514,9 @@ $_glow-gravity: 0 0 0.8rem 0.15rem rgba(var(--quaUser), 0.6);
.fan-card-name-group { font-size: calc(var(--sig-card-w, 140px) * 0.073); opacity: 0.6; }
.sig-qualifier-above,
.sig-qualifier-below,
.fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
.fan-card-reversal-qualifier { font-size: calc(var(--sig-card-w, 140px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-name,
.fan-card-reversal-name { font-size: calc(var(--sig-card-w, 140px) * 0.093); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; }
.fan-card-reversal-name { font-size: calc(var(--sig-card-w, 140px) * 0.08); font-weight: 600; color: rgba(var(--quiUser), 1); transition: opacity 0.2s; text-wrap: balance; }
.fan-card-arcana { font-size: calc(var(--sig-card-w, 140px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
.fan-card-reversal-qualifier,
.fan-card-reversal-name { transform: rotate(180deg); opacity: 0.25; }

View File

@@ -147,18 +147,18 @@ $handle-r: 1rem;
}
}
// Hosts the same compact rank-+-icon Sig card used in the Sea Select center
// (.sig-stage-card.sea-sig-card). Width is sized so the 5:8-aspect card
// height ≈ tray cell height.
.tray-sig-card {
padding: 0;
overflow: hidden;
padding: 0;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
transform: scale(1.4); // crop SVG's internal margins
.sig-stage-card.sea-sig-card {
--sig-card-w: calc(var(--tray-cell-size, 48px) * 5 / 8);
}
}

View File

@@ -13,7 +13,7 @@
<header class="sea-modal-header">
<h2>PICK <span>SEA</span></h2>
<p>Draw cards to circumscribe your character's influences and seed the Voronoi map.</p>
<p>Draw +6 cards to describe your character's influences and seed the map.</p>
</header>
<div class="sea-modal-body">
@@ -63,6 +63,13 @@
<div class="sea-form-main">
<div class="sea-field">
<label for="id_sea_spread" id="id_sea_spread_label">Spread</label>
{% comment %}
Reversal-rate hint — `stack_reversal_pct` flows from
apps.epic.utils.stack_reversal_probability via the
view. Currently a module default; placeholder UI for
a forthcoming per-user setting.
{% endcomment %}
<p class="sea-reversal-hint">{{ stack_reversal_pct|default:25 }}% reversals</p>
{% comment %}
Custom combobox — native <select> dropdowns ignore most CSS on
Firefox/Chrome (OS-rendered list); this gives full styling control.
@@ -144,7 +151,7 @@
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<h3 class="fan-card-name"></h3>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>

View File

@@ -26,7 +26,7 @@ Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_
<div class="fan-card-face-upright">
<p class="fan-card-name-group"></p>
<p class="sig-qualifier-above"></p>
<h3 class="fan-card-name"></h3>
<p class="fan-card-name"></p>
<p class="sig-qualifier-below"></p>
</div>
<p class="fan-card-arcana"></p>

View File

@@ -28,13 +28,13 @@
<div class="fan-card-face-upright">
{% if 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 #}
<h3 class="fan-card-name">{{ card.levity_emanation|italicize:card.italic_word }}</h3>
<p class="fan-card-name {{ card.title_squeeze_class }}">{{ card.levity_emanation|italicize:card.italic_word }}</p>
{% else %}
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
{% if card.arcana != "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-above">{{ card.levity_qualifier }}</p>
{% endif %}
<h3 class="fan-card-name">{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}</h3>
<p class="fan-card-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.arcana == "MAJOR" and card.levity_qualifier %},{% endif %}</p>
{% if card.arcana == "MAJOR" and card.levity_qualifier %}
<p class="sig-qualifier-below">{{ card.levity_qualifier }}</p>
{% endif %}
@@ -49,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. #}
<p class="fan-card-reversal-name"></p>
<p class="fan-card-reversal-qualifier">{{ card.levity_reversal|italicize:card.italic_word }}</p>
<p class="fan-card-reversal-qualifier {{ card.title_squeeze_class }}">{{ card.levity_reversal|italicize:card.italic_word }}</p>
{% elif card.arcana == "MAJOR" %}
<p class="fan-card-reversal-name">{{ card.levity_qualifier|default:card.gravity_qualifier }}</p>
<p class="fan-card-reversal-qualifier">{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}</p>
<p class="fan-card-reversal-qualifier {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}{% if card.levity_qualifier %},{% endif %}</p>
{% else %}
<p class="fan-card-reversal-name">{{ card.name_title|italicize:card.italic_word }}</p>
<p class="fan-card-reversal-name {{ card.title_squeeze_class }}">{{ card.name_title|italicize:card.italic_word }}</p>
<p class="fan-card-reversal-qualifier">{{ card.reversal_qualifier|default:card.gravity_qualifier }}</p>
{% endif %}
</div>

View File

@@ -101,7 +101,7 @@
<i class="fa-solid fa-dice-d20"></i>
</button>
</div>
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><img src="{% static 'apps/epic/icons/cards-sigs/Blank.svg' %}" alt="{{ my_tray_sig.name }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% if my_tray_sig %}<div class="tray-cell tray-sig-card"><div class="sig-stage-card sea-sig-card" aria-label="{{ my_tray_sig.name }}"><span class="fan-corner-rank">{{ my_tray_sig.corner_rank }}</span>{% if my_tray_sig.suit_icon %}<i class="fa-solid {{ my_tray_sig.suit_icon }}"></i>{% endif %}</div></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
</div>
{% endif %}
{% include "apps/gameboard/_partials/_room_gear.html" %}