From f107522b203d7c1e55f23723e53bceb6c102d2c6 Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sun, 24 May 2026 23:25:26 -0400 Subject: [PATCH] =?UTF-8?q?A.0=20image-rendering=20schema=20+=20RWS=20rena?= =?UTF-8?q?me=20+=20canonical-Earthman=20suit=20collapse=20=E2=80=94=20TDD?= =?UTF-8?q?.=20Sprint=20A.0=20of=20[[project-image-based-deck-face-renderi?= =?UTF-8?q?ng]].=20Adds=20three=20`DeckVariant`=20fields:=20`has=5Fcard=5F?= =?UTF-8?q?images`=20(BooleanField=20default=3DTrue=20=E2=80=94=20Earthman?= =?UTF-8?q?=20keeps=20False=20until=20its=20artwork=20ships,=20every=20new?= =?UTF-8?q?=20deck=20defaults=20True),=20`family`=20(CharField=20choices?= =?UTF-8?q?=3D[earthman,=20italian,=20english,=20playing]=20default=3Deart?= =?UTF-8?q?hman=20=E2=80=94=20drives=20per-family=20display=20+=20filename?= =?UTF-8?q?=20slug=20mapping=20per=20[[reference-card-image-naming-convent?= =?UTF-8?q?ion]]),=20`is=5Fpolarized`=20(BooleanField=20default=3DFalse=20?= =?UTF-8?q?=E2=80=94=20Earthman=20is=20True=20today;=20Sprint=20A.4=20game?= =?UTF-8?q?=5Fkit=20applet=20will=20render=20"(=C3=972)"=20in=20--terUser?= =?UTF-8?q?=20for=20polarized=20decks;=20Sprint=20C+B=20segment=20model=20?= =?UTF-8?q?uses=20it=20for=20segment-count=20logic).=20`TarotCard.SUIT=5FC?= =?UTF-8?q?HOICES`=20collapses=20from=208=20values=20to=204=20canonical=20?= =?UTF-8?q?Earthman=20values=20(BRANDS=20/=20CROWNS=20/=20GRAILS=20/=20BLA?= =?UTF-8?q?DES);=20WANDS=20/=20CUPS=20/=20SWORDS=20/=20PENTACLES=20dropped?= =?UTF-8?q?=20=E2=80=94=20they=20were=20duplicative=20at=20the=20structura?= =?UTF-8?q?l=20level=20since=20`sig=5Fdeck=5Fcards`=20+=20`levity/gravity?= =?UTF-8?q?=5Fsig=5Fcards`=20already=20treated=20[WANDS,=20BRANDS,=20CROWN?= =?UTF-8?q?S]=20as=20one=20segment=20and=20[SWORDS,=20BLADES,=20CUPS,=20GR?= =?UTF-8?q?AILS]=20as=20another=20(so=20the=20project=20already=20*functio?= =?UTF-8?q?nally*=20equated=20them;=20the=20lock=20just=20makes=20that=20e?= =?UTF-8?q?xplicit).=20Per-family=20display=20vocab=20(`batons`=20for=20It?= =?UTF-8?q?alian,=20`wands`=20for=20English,=20`clubs`=20for=20Playing)=20?= =?UTF-8?q?lives=20in=20Sprint=20A.2's=20`display=5Fsuit=5Fname`=20propert?= =?UTF-8?q?y,=20not=20in=20the=20enum.=20Audit=202026-05-25=20revealed=20t?= =?UTF-8?q?he=20existing=20`fiorentine-minchiate`=20DeckVariant=20is=20act?= =?UTF-8?q?ually=2078-card=20RWS=20Tarot=20in=20disguise=20(22=20majors=20?= =?UTF-8?q?numbered=200-21=20w.=20RWS=20names:=20The=20Fool=20/=20The=20Ma?= =?UTF-8?q?gician=20/=20...=20/=20The=20World;=2056=20minors=20in=204=20su?= =?UTF-8?q?its=20=C3=97=2014=20cards)=20=E2=80=94=20NOT=20Minchiate=20(whi?= =?UTF-8?q?ch=20has=2040=20trumps=20+=201=20Il=20Matto=20+=2056=20minors?= =?UTF-8?q?=20=3D=2097=20cards).=20Migration=200012=20renames=20the=20slug?= =?UTF-8?q?=20=E2=86=92=20`tarot-rider-waite-smith`,=20name=20=E2=86=92=20?= =?UTF-8?q?"Tarot=20(Rider-Waite-Smith)",=20sets=20family=3D'english',=20h?= =?UTF-8?q?as=5Fcard=5Fimages=3DFalse,=20is=5Fpolarized=3DFalse=20?= =?UTF-8?q?=E2=80=94=20and=20revocabs=20its=2056=20minor=20cards'=20suits?= =?UTF-8?q?=20in-place=20(WANDS=E2=86=92BRANDS,=20CUPS=E2=86=92GRAILS,=20S?= =?UTF-8?q?WORDS=E2=86=92BLADES,=20PENTACLES=E2=86=92CROWNS)=20so=20they?= =?UTF-8?q?=20match=20the=20new=20canonical=20enum.=20FKs=20(User.equipped?= =?UTF-8?q?=5Fdeck,=20User.unlocked=5Fdecks,=20TableSeat.deck=5Fvariant,?= =?UTF-8?q?=20etc.)=20survive=20untouched=20=E2=80=94=20slug-only=20change?= =?UTF-8?q?s=20don't=20break=20referential=20integrity.=20Earthman=20field?= =?UTF-8?q?s=20set=20explicitly=20in=200012=20too=20(family=3Dearthman,=20?= =?UTF-8?q?has=5Fcard=5Fimages=3DFalse,=20is=5Fpolarized=3DTrue).=20Compan?= =?UTF-8?q?ion=20code=20simplifications:=20`sig=5Fdeck=5Fcards`=20+=20`=5F?= =?UTF-8?q?sig=5Funique=5Fcards=5Ffor=5Fdeck`=20queries=20shrink=20from=20?= =?UTF-8?q?`suit=5F=5Fin=3D[3=20values]`=20and=20`[4=20values]`=20to=20`[2?= =?UTF-8?q?=20values]`=20each=20(one=20per=20segment);=20`TarotCard.suit?= =?UTF-8?q?=5Ficon`=20mapping=20shrinks=20from=208=20entries=20to=204;=20`?= =?UTF-8?q?gameboard.views.tarot=5Ffan.=5Fsuit=5Forder`=20shrinks=20from?= =?UTF-8?q?=208=20keys=20to=204.=20Existing=20test=20files=20updated:=20`t?= =?UTF-8?q?est=5Fgame=5Froom=5Ftray.py`=20(largest=20update=20=E2=80=94=20?= =?UTF-8?q?`self.fiorentine`=20=E2=86=92=20`self.rws`,=20`id=5Fkit=5Ffiore?= =?UTF-8?q?ntine=5Fdeck`=20=E2=86=92=20`id=5Fkit=5Ftarot=5Fdeck`=20(templa?= =?UTF-8?q?te-id=20derives=20from=20deck.short=5Fkey=20=3D=20first=20slug?= =?UTF-8?q?=20segment),=20assertion=20"Fiorentine"=20=E2=86=92=20"Rider-Wa?= =?UTF-8?q?ite-Smith");=20`test=5Fgame=5Froom=5Fdeck=5Fcontrib.py`=20(same?= =?UTF-8?q?=20pattern,=20smaller);=20`lyric/test=5Fmodels.py`=20+=20`gameb?= =?UTF-8?q?oard/test=5Fviews.py`=20(slug=20literal=20swaps=20only);=20`epi?= =?UTF-8?q?c/test=5Fmodels.py`=20`=5Fmake=5Fsig=5Fcard`=20test=20fixtures:?= =?UTF-8?q?=20"WANDS"=E2=86=92"BRANDS",=20"CUPS"=E2=86=92"GRAILS".=2014=20?= =?UTF-8?q?new=20ITs=20in=20`DeckSchemaA0Test`=20cover=20the=20schema=20ad?= =?UTF-8?q?ditions=20+=20migration=20outcomes=20(field=20existence=20+=20c?= =?UTF-8?q?hoice=20values=20+=20earthman=20has=20all=20three=20fields=20se?= =?UTF-8?q?t=20correctly=20+=20RWS=20rename=20verified=20+=20RWS=20cards?= =?UTF-8?q?=20use=20canonical=20suits=20+=20dropped=20enum=20values=20abse?= =?UTF-8?q?nt=20from=20SUIT=5FCHOICES).=20Tests:=2014=20new=20green;=20125?= =?UTF-8?q?5/1255=20IT+UT=20total=20green=20(38s);=20no=20regressions.=20O?= =?UTF-8?q?ut=20of=20scope:=20Sprint=20A.1=20will=20seed=20the=20actual=20?= =?UTF-8?q?Minchiate=20Fiorentine=201860-1890=20(97-card)=20DeckVariant=20?= =?UTF-8?q?+=20TarotCard=20rows=20w.=20family=3D'italian',=20has=5Fcard=5F?= =?UTF-8?q?images=3DTrue;=20A.2=20adds=20the=20`image=5Ffilename`=20+=20`d?= =?UTF-8?q?isplay=5Fsuit=5Fname`=20properties=20that=20consume=20the=20new?= =?UTF-8?q?=20`family`=20field;=20A.3+=20wires=20the=20render=20branches?= =?UTF-8?q?=20across=206=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ly_deckvariant_has_card_images_and_more.py | 33 +++++++ .../0012_rws_rename_and_suit_revocab.py | 70 ++++++++++++++ src/apps/epic/models.py | 93 ++++++++---------- src/apps/epic/tests/integrated/test_models.py | 95 ++++++++++++++++++- .../gameboard/tests/integrated/test_views.py | 6 +- src/apps/gameboard/views.py | 3 +- .../lyric/tests/integrated/test_models.py | 4 +- .../test_game_room_deck_contrib.py | 12 +-- src/functional_tests/test_game_room_tray.py | 26 ++--- 9 files changed, 260 insertions(+), 82 deletions(-) create mode 100644 src/apps/epic/migrations/0011_deckvariant_family_deckvariant_has_card_images_and_more.py create mode 100644 src/apps/epic/migrations/0012_rws_rename_and_suit_revocab.py diff --git a/src/apps/epic/migrations/0011_deckvariant_family_deckvariant_has_card_images_and_more.py b/src/apps/epic/migrations/0011_deckvariant_family_deckvariant_has_card_images_and_more.py new file mode 100644 index 0000000..b59d435 --- /dev/null +++ b/src/apps/epic/migrations/0011_deckvariant_family_deckvariant_has_card_images_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0 on 2026-05-25 03:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0010_set_reversal_drops_qualifier_realms'), + ] + + operations = [ + migrations.AddField( + model_name='deckvariant', + name='family', + field=models.CharField(choices=[('earthman', 'Earthman'), ('italian', 'Italian / Minchiate'), ('english', 'English Tarot'), ('playing', 'Playing card')], default='earthman', max_length=10), + ), + migrations.AddField( + model_name='deckvariant', + name='has_card_images', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='deckvariant', + name='is_polarized', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='tarotcard', + name='suit', + field=models.CharField(blank=True, choices=[('BRANDS', 'Brands'), ('CROWNS', 'Crowns'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True), + ), + ] diff --git a/src/apps/epic/migrations/0012_rws_rename_and_suit_revocab.py b/src/apps/epic/migrations/0012_rws_rename_and_suit_revocab.py new file mode 100644 index 0000000..d44b31d --- /dev/null +++ b/src/apps/epic/migrations/0012_rws_rename_and_suit_revocab.py @@ -0,0 +1,70 @@ +"""Sprint A.0 data migration. + +The existing `fiorentine-minchiate` DeckVariant is actually 78-card Rider-Waite-Smith +Tarot (22 majors numbered 0-21 with RWS names + 56 minors in WANDS/CUPS/SWORDS/PENTACLES). +Rename to its true identity, set the new schema fields (family / has_card_images / +is_polarized), and revocab card suits to the canonical Earthman vocabulary that +SUIT_CHOICES now requires. Earthman gets its new-field values set too. FKs remain +intact since slug-only changes don't break referential integrity. + +The actual Minchiate Fiorentine deck (97 cards, 1860-1890 publication, image set +already imported per [[reference-card-image-naming-convention]]) is seeded in +Sprint A.1's follow-up migration. +""" +from django.db import migrations + + +SUIT_REVOCAB = { + "WANDS": "BRANDS", + "CUPS": "GRAILS", + "SWORDS": "BLADES", + "PENTACLES": "CROWNS", +} + + +def forward(apps, schema_editor): + DeckVariant = apps.get_model("epic", "DeckVariant") + TarotCard = apps.get_model("epic", "TarotCard") + + # Earthman: set new-field values explicitly (default=True for has_card_images + # would have left it True after the schema migration — wrong for Earthman). + DeckVariant.objects.filter(slug="earthman").update( + family="earthman", has_card_images=False, is_polarized=True, + ) + + # fiorentine-minchiate → RWS Tarot rename + new-field values. + rws = DeckVariant.objects.filter(slug="fiorentine-minchiate").first() + if rws: + rws.slug = "tarot-rider-waite-smith" + rws.name = "Tarot (Rider-Waite-Smith)" + rws.family = "english" + rws.has_card_images = False + rws.is_polarized = False + rws.save() + # Revocab the 56 minor cards' suits to canonical Earthman vocab. + for old_suit, new_suit in SUIT_REVOCAB.items(): + TarotCard.objects.filter(deck_variant=rws, suit=old_suit).update( + suit=new_suit, + ) + + +def backward(apps, schema_editor): + DeckVariant = apps.get_model("epic", "DeckVariant") + TarotCard = apps.get_model("epic", "TarotCard") + + rws = DeckVariant.objects.filter(slug="tarot-rider-waite-smith").first() + if rws: + for new_suit, old_suit in {v: k for k, v in SUIT_REVOCAB.items()}.items(): + TarotCard.objects.filter(deck_variant=rws, suit=new_suit).update( + suit=old_suit, + ) + rws.slug = "fiorentine-minchiate" + rws.name = "Fiorentine Minchiate" + rws.save() + + +class Migration(migrations.Migration): + dependencies = [ + ("epic", "0011_deckvariant_family_deckvariant_has_card_images_and_more"), + ] + operations = [migrations.RunPython(forward, backward)] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index b31e40a..78a8c9c 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -221,13 +221,27 @@ class TableSeat(models.Model): class DeckVariant(models.Model): - """A named deck variant, e.g. Earthman (108 cards) or Fiorentine Minchiate (78 cards).""" + """A named deck variant, e.g. Earthman or Tarot (Rider-Waite-Smith).""" + + EARTHMAN = "earthman" + ITALIAN = "italian" + ENGLISH = "english" + PLAYING = "playing" + FAMILY_CHOICES = [ + (EARTHMAN, "Earthman"), + (ITALIAN, "Italian / Minchiate"), + (ENGLISH, "English Tarot"), + (PLAYING, "Playing card"), + ] name = models.CharField(max_length=100, unique=True) slug = models.SlugField(unique=True) card_count = models.IntegerField() description = models.TextField(blank=True) is_default = models.BooleanField(default=False) + family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN) + has_card_images = models.BooleanField(default=True) + is_polarized = models.BooleanField(default=False) @property def short_key(self): @@ -248,23 +262,18 @@ class TarotCard(models.Model): (MIDDLE, "Middle Arcana"), ] - WANDS = "WANDS" - CUPS = "CUPS" - SWORDS = "SWORDS" - PENTACLES = "PENTACLES" # Fiorentine 4th suit - CROWNS = "CROWNS" # Earthman 4th suit - BRANDS = "BRANDS" # Earthman Wands - GRAILS = "GRAILS" # Earthman Cups - BLADES = "BLADES" # Earthman Swords + # Canonical SUIT_CHOICES = Earthman vocabulary (2026-05-25 lock). + # Per-family display + filename slug mapping lives in image_filename / + # display_suit_name properties driven by DeckVariant.family. + BRANDS = "BRANDS" + CROWNS = "CROWNS" + GRAILS = "GRAILS" + BLADES = "BLADES" SUIT_CHOICES = [ - (WANDS, "Wands"), - (CUPS, "Cups"), - (SWORDS, "Swords"), - (PENTACLES, "Pentacles"), - (CROWNS, "Crowns"), - (BRANDS, "Brands"), - (GRAILS, "Grails"), - (BLADES, "Blades"), + (BRANDS, "Brands"), + (CROWNS, "Crowns"), + (GRAILS, "Grails"), + (BLADES, "Blades"), ] deck_variant = models.ForeignKey( @@ -437,14 +446,10 @@ class TarotCard(models.Model): if self.arcana == self.MAJOR: return '' return { - self.WANDS: 'fa-wand-sparkles', - self.CUPS: 'fa-trophy', - 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', + self.BRANDS: 'fa-wand-sparkles', + self.CROWNS: 'fa-crown', + self.GRAILS: 'fa-trophy', + self.BLADES: 'fa-gun', }.get(self.suit, '') @property @@ -558,32 +563,12 @@ def _room_deck_variant(room): def sig_deck_cards(room): """Return 36 TarotCard objects forming the Significator deck (18 unique × 2). - 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 + PC/BC pair → BRANDS + CROWNS Middle Arcana court cards (11–14): 8 unique + SC/AC pair → BLADES + GRAILS 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_deck_variant(room) - if deck_variant is None: - return [] - wands_crowns = list(TarotCard.objects.filter( - deck_variant=deck_variant, - arcana=TarotCard.MIDDLE, - 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.BLADES, TarotCard.CUPS, TarotCard.GRAILS], - number__in=[11, 12, 13, 14], - )) - major = list(TarotCard.objects.filter( - deck_variant=deck_variant, - arcana=TarotCard.MAJOR, - number__in=[0, 1], - )) - unique_cards = wands_crowns + swords_cups + major # 18 unique + unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room)) return unique_cards + unique_cards # × 2 = 36 @@ -594,16 +579,16 @@ def _sig_unique_cards_for_deck(deck_variant): via personal_sig_cards from User.equipped_deck).""" if deck_variant is None: return [] - wands_crowns = list(TarotCard.objects.filter( + brands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS], + suit__in=[TarotCard.BRANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) - swords_cups = list(TarotCard.objects.filter( + blades_grails = list(TarotCard.objects.filter( deck_variant=deck_variant, arcana=TarotCard.MIDDLE, - suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS], + suit__in=[TarotCard.BLADES, TarotCard.GRAILS], number__in=[11, 12, 13, 14], )) major = list(TarotCard.objects.filter( @@ -611,7 +596,7 @@ def _sig_unique_cards_for_deck(deck_variant): arcana=TarotCard.MAJOR, number__in=[0, 1], )) - return wands_crowns + swords_cups + major + return brands_crowns + blades_grails + major def _sig_unique_cards(room): diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index fc664fc..7068370 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -521,7 +521,7 @@ class SigReservationModelTest(TestCase): ) self.owner = User.objects.create(email="founder@test.io") self.room = Room.objects.create(name="Sig Room", owner=self.owner) - self.card = _make_sig_card(self.earthman, "WANDS", 14) + self.card = _make_sig_card(self.earthman, "BRANDS", 14) self.seat = TableSeat.objects.create( room=self.room, gamer=self.owner, slot_number=1, role="PC" ) @@ -538,7 +538,7 @@ class SigReservationModelTest(TestCase): SigReservation.objects.create( room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity" ) - card2 = _make_sig_card(self.earthman, "CUPS", 13) + card2 = _make_sig_card(self.earthman, "GRAILS", 13) with self.assertRaises(IntegrityError): SigReservation.objects.create( room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity" @@ -946,3 +946,94 @@ class CharacterModelTest(TestCase): retired_at=timezone.now(), ) self.assertFalse(char.is_active) + + +class DeckSchemaA0Test(TestCase): + """Sprint A.0 — DeckVariant gains has_card_images, family, is_polarized; + SUIT_CHOICES collapses to canonical Earthman vocab; existing + fiorentine-minchiate seed (which was actually RWS Tarot in disguise) + gets renamed to tarot-rider-waite-smith with revocabbed suits.""" + + def test_deck_variant_has_card_images_field(self): + f = DeckVariant._meta.get_field("has_card_images") + self.assertEqual(f.get_internal_type(), "BooleanField") + + def test_deck_variant_family_field_has_expected_choices(self): + f = DeckVariant._meta.get_field("family") + choice_values = {c[0] for c in f.choices} + self.assertEqual(choice_values, {"earthman", "italian", "english", "playing"}) + + def test_deck_variant_is_polarized_field(self): + f = DeckVariant._meta.get_field("is_polarized") + self.assertEqual(f.get_internal_type(), "BooleanField") + + def test_earthman_is_polarized(self): + earthman = DeckVariant.objects.get(slug="earthman") + self.assertTrue(earthman.is_polarized) + + def test_earthman_family_is_earthman(self): + earthman = DeckVariant.objects.get(slug="earthman") + self.assertEqual(earthman.family, "earthman") + + def test_earthman_has_card_images_false_until_artwork_painted(self): + earthman = DeckVariant.objects.get(slug="earthman") + self.assertFalse(earthman.has_card_images) + + def test_rws_deck_renamed_from_fiorentine_minchiate(self): + self.assertFalse( + DeckVariant.objects.filter(slug="fiorentine-minchiate").exists(), + "fiorentine-minchiate slug should have been renamed to tarot-rider-waite-smith", + ) + self.assertTrue( + DeckVariant.objects.filter(slug="tarot-rider-waite-smith").exists(), + ) + + def test_rws_deck_name_is_canonical(self): + rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + self.assertEqual(rws.name, "Tarot (Rider-Waite-Smith)") + + def test_rws_family_is_english(self): + rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + self.assertEqual(rws.family, "english") + + def test_rws_has_card_images_false(self): + rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + self.assertFalse(rws.has_card_images) + + def test_rws_is_polarized_false(self): + rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + self.assertFalse(rws.is_polarized) + + def test_rws_cards_use_canonical_earthman_suits(self): + rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + suit_values = set( + TarotCard.objects.filter(deck_variant=rws, arcana="MINOR") + .values_list("suit", flat=True) + .distinct() + ) + self.assertEqual( + suit_values, + {"BRANDS", "GRAILS", "BLADES", "CROWNS"}, + "RWS suits should have been revocabbed: WANDS→BRANDS, CUPS→GRAILS, " + "SWORDS→BLADES, PENTACLES→CROWNS", + ) + + def test_rws_minor_card_count_preserved(self): + """Revocab is a string swap on the suit field — count is invariant.""" + rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith") + for canonical_suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS"): + self.assertEqual( + TarotCard.objects.filter( + deck_variant=rws, arcana="MINOR", suit=canonical_suit + ).count(), + 14, + f"{canonical_suit} should have 14 cards in RWS", + ) + + def test_suit_choices_dropped_english_vocab(self): + choice_values = {c[0] for c in TarotCard.SUIT_CHOICES} + self.assertEqual(choice_values, {"BRANDS", "GRAILS", "BLADES", "CROWNS"}) + self.assertNotIn("WANDS", choice_values) + self.assertNotIn("CUPS", choice_values) + self.assertNotIn("SWORDS", choice_values) + self.assertNotIn("PENTACLES", choice_values) diff --git a/src/apps/gameboard/tests/integrated/test_views.py b/src/apps/gameboard/tests/integrated/test_views.py index eda61d5..26054b6 100644 --- a/src/apps/gameboard/tests/integrated/test_views.py +++ b/src/apps/gameboard/tests/integrated/test_views.py @@ -400,11 +400,11 @@ class GameboardDeckInUseTest(TestCase): self.assertEqual("Wildfire", el.get("data-in-use-room-name")) def test_non_in_use_deck_has_normal_don(self): - fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith") self.user.unlocked_decks.add(fiorentine) response = self.client.get("/gameboard/") parsed = lxml.html.fromstring(response.content) - [don] = parsed.cssselect("#id_kit_fiorentine_deck .btn-equip") + [don] = parsed.cssselect("#id_kit_tarot_deck .btn-equip") self.assertNotIn("btn-disabled", don.get("class", "")) @@ -697,7 +697,7 @@ class TarotFanViewTest(TestCase): def setUp(self): from apps.epic.models import DeckVariant self.earthman = DeckVariant.objects.get(slug="earthman") - self.fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + self.fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith") self.user = User.objects.create(email="fan@test.io") self.client.force_login(self.user) diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index 8eff026..9ff7b6a 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -630,8 +630,7 @@ def tarot_fan(request, deck_id): deck = get_object_or_404(DeckVariant, pk=deck_id) if not request.user.unlocked_decks.filter(pk=deck_id).exists(): return HttpResponse(status=403) - _suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3, - "WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3} + _suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3} cards = sorted( TarotCard.objects.filter(deck_variant=deck), key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number), diff --git a/src/apps/lyric/tests/integrated/test_models.py b/src/apps/lyric/tests/integrated/test_models.py index 5e7d3ec..5423800 100644 --- a/src/apps/lyric/tests/integrated/test_models.py +++ b/src/apps/lyric/tests/integrated/test_models.py @@ -409,14 +409,14 @@ class EquippedDeckTest(TestCase): def test_fiorentine_is_not_auto_assigned_to_new_users(self): from apps.epic.models import DeckVariant - fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith") user = User.objects.create(email="deck2@test.io") user.refresh_from_db() self.assertNotEqual(user.equipped_deck, fiorentine) def test_equipped_deck_can_be_switched(self): from apps.epic.models import DeckVariant - fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate") + fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith") user = User.objects.create(email="deck3@test.io") user.equipped_deck = fiorentine user.save(update_fields=["equipped_deck"]) diff --git a/src/functional_tests/test_game_room_deck_contrib.py b/src/functional_tests/test_game_room_deck_contrib.py index 5b69225..e36810e 100644 --- a/src/functional_tests/test_game_room_deck_contrib.py +++ b/src/functional_tests/test_game_room_deck_contrib.py @@ -182,16 +182,16 @@ class DeckInUseGameKitTest(FunctionalTest): def test_non_contributing_deck_has_normal_don_doff(self): """A deck not assigned to any active seat shows the normal DON/DOFF apparatus.""" gamer, earthman, room, seat = self._setup_in_use_deck() - # Unlock Fiorentine for the gamer so it appears in Game Kit - fiorentine, _ = DeckVariant.objects.get_or_create( - slug="fiorentine-minchiate", - defaults={"name": "Fiorentine Minchiate", "card_count": 97}, + # Unlock RWS for the gamer so it appears in Game Kit + rws, _ = DeckVariant.objects.get_or_create( + slug="tarot-rider-waite-smith", + defaults={"name": "Tarot (Rider-Waite-Smith)", "card_count": 78}, ) - gamer.unlocked_decks.add(fiorentine) + gamer.unlocked_decks.add(rws) self.create_pre_authenticated_session(GAMER_EMAIL) self.browser.get(self.live_server_url + "/gameboard/") self.wait_for( - lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_fiorentine_deck") + lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_kit_tarot_deck") ).click() don_btn = self.wait_for( lambda: self.browser.find_element( diff --git a/src/functional_tests/test_game_room_tray.py b/src/functional_tests/test_game_room_tray.py index e0c4469..42df792 100644 --- a/src/functional_tests/test_game_room_tray.py +++ b/src/functional_tests/test_game_room_tray.py @@ -527,17 +527,17 @@ class GameKitDeckSelectionTest(FunctionalTest): slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) - self.fiorentine, _ = DeckVariant.objects.get_or_create( - slug="fiorentine-minchiate", - defaults={"name": "Fiorentine Minchiate", "card_count": 78, "is_default": False}, + self.rws, _ = DeckVariant.objects.get_or_create( + slug="tarot-rider-waite-smith", + defaults={"name": "Tarot (Rider-Waite-Smith)", "card_count": 78, "is_default": False}, ) self.gamer = User.objects.create(email="gamer@deck.io") # Signal sets equipped_deck = earthman and unlocked_decks = [earthman]. # Explicitly grant fiorentine too, then switch equipped_deck to it so # the test can exercise switching back to Earthman. self.gamer.refresh_from_db() - self.gamer.unlocked_decks.add(self.fiorentine) - self.gamer.equipped_deck = self.fiorentine + self.gamer.unlocked_decks.add(self.rws) + self.gamer.equipped_deck = self.rws self.gamer.save(update_fields=["equipped_deck"]) # ------------------------------------------------------------------ # @@ -578,23 +578,23 @@ class GameKitDeckSelectionTest(FunctionalTest): don = portal.find_element(By.CSS_SELECTOR, ".btn-equip") self.assertNotIn("btn-disabled", don.get_attribute("class")) - # ── Hover over Fiorentine Minchiate deck ───────────────────────── - fiorentine_el = self.browser.find_element(By.ID, "id_kit_fiorentine_deck") + # ── Hover over Tarot (Rider-Waite-Smith) deck ──────────────────── + rws_el = self.browser.find_element(By.ID, "id_kit_tarot_deck") self.browser.execute_script( - "arguments[0].scrollIntoView({block: 'center'})", fiorentine_el + "arguments[0].scrollIntoView({block: 'center'})", rws_el ) - ActionChains(self.browser).move_to_element(fiorentine_el).perform() + ActionChains(self.browser).move_to_element(rws_el).perform() self.wait_for( lambda: self.assertIn( - "Fiorentine", + "Rider-Waite-Smith", self.browser.find_element(By.ID, "id_tooltip_portal").text, ) ) portal = self.browser.find_element(By.ID, "id_tooltip_portal") self.assertIn("78", portal.text) - # Mini tooltip shows "Equipped" — Fiorentine is the active deck + # Mini tooltip shows "Equipped" — RWS is the active deck mini = self.browser.find_element(By.ID, "id_mini_tooltip_portal") self.wait_for(lambda: self.assertTrue(mini.is_displayed())) self.assertIn("Equipped", mini.text) @@ -639,8 +639,8 @@ class GameKitDeckSelectionTest(FunctionalTest): deck_cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_game_kit .deck-variant") self.assertEqual(len(deck_cards), 1) self.browser.find_element(By.ID, "id_kit_earthman_deck") - fiorentine_cards = self.browser.find_elements(By.ID, "id_kit_fiorentine_deck") - self.assertEqual(len(fiorentine_cards), 0) + rws_cards = self.browser.find_elements(By.ID, "id_kit_tarot_deck") + self.assertEqual(len(rws_cards), 0) class GameKitPageTest(FunctionalTest):