diff --git a/src/apps/epic/migrations/0023_tarotcard_icon_alter_tarotcard_arcana_and_more.py b/src/apps/epic/migrations/0023_tarotcard_icon_alter_tarotcard_arcana_and_more.py new file mode 100644 index 0000000..241aaf4 --- /dev/null +++ b/src/apps/epic/migrations/0023_tarotcard_icon_alter_tarotcard_arcana_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0 on 2026-04-06 02:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('epic', '0022_sig_reservation'), + ] + + operations = [ + migrations.AddField( + model_name='tarotcard', + name='icon', + field=models.CharField(blank=True, default='', max_length=50), + ), + migrations.AlterField( + model_name='tarotcard', + name='arcana', + field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6), + ), + migrations.AlterField( + model_name='tarotcard', + name='suit', + field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True), + ), + ] diff --git a/src/apps/epic/migrations/0024_earthman_pentacles_to_crowns.py b/src/apps/epic/migrations/0024_earthman_pentacles_to_crowns.py new file mode 100644 index 0000000..830072e --- /dev/null +++ b/src/apps/epic/migrations/0024_earthman_pentacles_to_crowns.py @@ -0,0 +1,46 @@ +""" +Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS. + +Updates for every Earthman card where suit="PENTACLES": + - suit: "PENTACLES" → "CROWNS" + - name: " of Pentacles" → " of Crowns" + - slug: "pentacles" → "crowns" +""" +from django.db import migrations + + +def pentacles_to_crowns(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + earthman = DeckVariant.objects.filter(slug="earthman").first() + if not earthman: + return + for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"): + card.suit = "CROWNS" + card.name = card.name.replace(" of Pentacles", " of Crowns") + card.slug = card.slug.replace("pentacles", "crowns") + card.save(update_fields=["suit", "name", "slug"]) + + +def crowns_to_pentacles(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + earthman = DeckVariant.objects.filter(slug="earthman").first() + if not earthman: + return + for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"): + card.suit = "PENTACLES" + card.name = card.name.replace(" of Crowns", " of Pentacles") + card.slug = card.slug.replace("crowns", "pentacles") + card.save(update_fields=["suit", "name", "slug"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"), + ] + + operations = [ + migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles), + ] diff --git a/src/apps/epic/migrations/0025_earthman_middle_arcana_and_major_icons.py b/src/apps/epic/migrations/0025_earthman_middle_arcana_and_major_icons.py new file mode 100644 index 0000000..55b3d18 --- /dev/null +++ b/src/apps/epic/migrations/0025_earthman_middle_arcana_and_major_icons.py @@ -0,0 +1,62 @@ +""" +Data migration: Earthman deck — court cards and major arcana icons. + + 1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE" + 2. Major arcana icons (stored in TarotCard.icon): + 0 (Nomad) → fa-hat-cowboy-side + 1 (Schizo) → fa-hat-wizard + 2–51 (rest) → fa-hand-dots +""" +from django.db import migrations + + +MAJOR_ICONS = { + 0: "fa-hat-cowboy-side", + 1: "fa-hat-wizard", +} +DEFAULT_MAJOR_ICON = "fa-hand-dots" + + +def forward(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + earthman = DeckVariant.objects.filter(slug="earthman").first() + if not earthman: + return + + # Court cards → MIDDLE + TarotCard.objects.filter( + deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14] + ).update(arcana="MIDDLE") + + # Major arcana icons + for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"): + card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON) + card.save(update_fields=["icon"]) + + +def backward(apps, schema_editor): + TarotCard = apps.get_model("epic", "TarotCard") + DeckVariant = apps.get_model("epic", "DeckVariant") + earthman = DeckVariant.objects.filter(slug="earthman").first() + if not earthman: + return + + TarotCard.objects.filter( + deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14] + ).update(arcana="MINOR") + + TarotCard.objects.filter( + deck_variant=earthman, arcana="MAJOR" + ).update(icon="") + + +class Migration(migrations.Migration): + + dependencies = [ + ("epic", "0024_earthman_pentacles_to_crowns"), + ] + + operations = [ + migrations.RunPython(forward, reverse_code=backward), + ] diff --git a/src/apps/epic/models.py b/src/apps/epic/models.py index ba30262..743f551 100644 --- a/src/apps/epic/models.py +++ b/src/apps/epic/models.py @@ -205,20 +205,24 @@ class DeckVariant(models.Model): class TarotCard(models.Model): MAJOR = "MAJOR" MINOR = "MINOR" + MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K) ARCANA_CHOICES = [ - (MAJOR, "Major Arcana"), - (MINOR, "Minor Arcana"), + (MAJOR, "Major Arcana"), + (MINOR, "Minor Arcana"), + (MIDDLE, "Middle Arcana"), ] WANDS = "WANDS" CUPS = "CUPS" SWORDS = "SWORDS" PENTACLES = "PENTACLES" # Fiorentine 4th suit + CROWNS = "CROWNS" # Earthman 4th suit (renamed from Pentacles) SUIT_CHOICES = [ - (WANDS, "Wands"), - (CUPS, "Cups"), - (SWORDS, "Swords"), + (WANDS, "Wands"), + (CUPS, "Cups"), + (SWORDS, "Swords"), (PENTACLES, "Pentacles"), + (CROWNS, "Crowns"), ] deck_variant = models.ForeignKey( @@ -226,8 +230,9 @@ class TarotCard(models.Model): on_delete=models.CASCADE, related_name="cards", ) name = models.CharField(max_length=200) - arcana = models.CharField(max_length=5, choices=ARCANA_CHOICES) + arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES) suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True) + icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana) number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor slug = models.SlugField(max_length=120) correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent @@ -275,6 +280,8 @@ class TarotCard(models.Model): @property def suit_icon(self): + if self.icon: + return self.icon if self.arcana == self.MAJOR: return '' return { @@ -282,6 +289,7 @@ class TarotCard(models.Model): self.CUPS: 'fa-trophy', self.SWORDS: 'fa-gun', self.PENTACLES: 'fa-star', + self.CROWNS: 'fa-crown', }.get(self.suit, '') def __str__(self): @@ -361,23 +369,23 @@ class SigReservation(models.Model): def sig_deck_cards(room): """Return 36 TarotCard objects forming the Significator deck (18 unique × 2). - PC/BC pair → WANDS + PENTACLES court cards (numbers 11–14): 8 unique - SC/AC pair → SWORDS + CUPS court cards (numbers 11–14): 8 unique - NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique + PC/BC pair → WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique + SC/AC pair → SWORDS + CUPS Middle Arcana court cards (11–14): 8 unique + NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique Total: 18 unique × 2 (levity + gravity piles) = 36 cards. """ deck_variant = room.owner.equipped_deck if deck_variant is None: return [] - wands_pentacles = list(TarotCard.objects.filter( + wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, - arcana=TarotCard.MINOR, - suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], + arcana=TarotCard.MIDDLE, + suit__in=[TarotCard.WANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, - arcana=TarotCard.MINOR, + arcana=TarotCard.MIDDLE, suit__in=[TarotCard.SWORDS, TarotCard.CUPS], number__in=[11, 12, 13, 14], )) @@ -386,7 +394,7 @@ def sig_deck_cards(room): arcana=TarotCard.MAJOR, number__in=[0, 1], )) - unique_cards = wands_pentacles + swords_cups + major # 18 unique + unique_cards = wands_crowns + swords_cups + major # 18 unique return unique_cards + unique_cards # × 2 = 36 @@ -395,15 +403,15 @@ def _sig_unique_cards(room): deck_variant = room.owner.equipped_deck if deck_variant is None: return [] - wands_pentacles = list(TarotCard.objects.filter( + wands_crowns = list(TarotCard.objects.filter( deck_variant=deck_variant, - arcana=TarotCard.MINOR, - suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], + arcana=TarotCard.MIDDLE, + suit__in=[TarotCard.WANDS, TarotCard.CROWNS], number__in=[11, 12, 13, 14], )) swords_cups = list(TarotCard.objects.filter( deck_variant=deck_variant, - arcana=TarotCard.MINOR, + arcana=TarotCard.MIDDLE, suit__in=[TarotCard.SWORDS, TarotCard.CUPS], number__in=[11, 12, 13, 14], )) @@ -412,7 +420,7 @@ def _sig_unique_cards(room): arcana=TarotCard.MAJOR, number__in=[0, 1], )) - return wands_pentacles + swords_cups + major + return wands_crowns + swords_cups + major def levity_sig_cards(room): diff --git a/src/apps/epic/tests/integrated/test_models.py b/src/apps/epic/tests/integrated/test_models.py index 64b44c4..b3bf67a 100644 --- a/src/apps/epic/tests/integrated/test_models.py +++ b/src/apps/epic/tests/integrated/test_models.py @@ -276,9 +276,9 @@ class SigDeckCompositionTest(TestCase): self.assertEqual(len(sc_ac), 16) self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac)) - def test_pc_bc_contribute_court_cards_of_wands_and_pentacles(self): + def test_pc_bc_contribute_court_cards_of_wands_and_crowns(self): cards = sig_deck_cards(self.room) - pc_bc = [c for c in cards if c.suit in ("WANDS", "PENTACLES")] + pc_bc = [c for c in cards if c.suit in ("WANDS", "CROWNS")] self.assertEqual(len(pc_bc), 16) self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc)) @@ -342,7 +342,7 @@ class SigCardFieldTest(TestCase): defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True}, ) self.card = TarotCard.objects.get( - deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11, + deck_variant=earthman, arcana="MIDDLE", suit="WANDS", number=11, ) owner = User.objects.create(email="owner@test.io") room = Room.objects.create(name="Field Test", owner=owner) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index e38d3ac..28caa71 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -926,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None): room.table_status = Room.SIG_SELECT room.save() card_in_deck = TarotCard.objects.get( - deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11 + deck_variant=earthman, arcana="MIDDLE", suit="WANDS", number=11 ) test_case.client.force_login(founder) return room, gamers, earthman, card_in_deck @@ -1188,7 +1188,7 @@ class SigReserveViewTest(TestCase): def test_reserve_different_card_while_holding_returns_409(self): """Cannot OK a different card while holding one — must NVM first.""" card_b = TarotCard.objects.filter( - deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=12 + deck_variant=self.earthman, arcana="MIDDLE", suit="WANDS", number=12 ).first() self._reserve() # PC grabs card A → 200 response = self._reserve(card_id=card_b.id) # tries card B → 409 @@ -1210,7 +1210,7 @@ class SigReserveViewTest(TestCase): def test_reserve_blocked_then_unblocked_after_release(self): """After NVM, a new card can be OK'd.""" card_b = TarotCard.objects.filter( - deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=12 + deck_variant=self.earthman, arcana="MIDDLE", suit="WANDS", number=12 ).first() self._reserve() # hold card A self._reserve(action="release") # NVM @@ -1270,3 +1270,13 @@ class SigReserveViewTest(TestCase): with patch("apps.epic.views._notify_sig_reserved") as mock_notify: self._reserve(action="release") mock_notify.assert_called_once() + + def test_release_broadcasts_card_id_so_second_browser_can_clear_it(self): + """WS release event must include the card_id; otherwise the receiving + browser can't find the card element to remove .sig-reserved--own.""" + self._reserve() + with patch("apps.epic.views._notify_sig_reserved") as mock_notify: + self._reserve(action="release") + args, kwargs = mock_notify.call_args + self.assertEqual(args[1], self.card.pk) # card_id must not be None + self.assertFalse(kwargs['reserved']) # reserved=False diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index aa287ed..1f618d3 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -579,8 +579,10 @@ def sig_reserve(request, room_id): action = request.POST.get("action", "reserve") if action == "release": + existing = SigReservation.objects.filter(room=room, gamer=request.user).first() + released_card_id = existing.card_id if existing else None SigReservation.objects.filter(room=room, gamer=request.user).delete() - _notify_sig_reserved(room_id, None, user_seat.role, reserved=False) + _notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False) return HttpResponse(status=200) # Reserve action diff --git a/src/apps/gameboard/views.py b/src/apps/gameboard/views.py index ab3d877..42dc8a0 100644 --- a/src/apps/gameboard/views.py +++ b/src/apps/gameboard/views.py @@ -150,7 +150,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 = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "COINS": 4} + _suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "CROWNS": 3, "COINS": 4} 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/static/tests/SigSelectSpec.js b/src/static/tests/SigSelectSpec.js index 61f376f..1eb928f 100644 --- a/src/static/tests/SigSelectSpec.js +++ b/src/static/tests/SigSelectSpec.js @@ -212,4 +212,37 @@ describe("SigSelect", () => { expect(card.classList.contains("sig-focused")).toBe(true); }); }); + + // ── WS release clears NVM in a second browser ─────────────────────── // + // Simulates the same gamer having two tabs open: tab B must clear its + // .sig-reserved--own when tab A presses NVM (WS release event arrives). + // The release payload must carry the card_id so the JS can find the element. + + describe("WS release event (second-browser NVM sync)", () => { + beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' })); + + it("removes .sig-reserved and .sig-reserved--own on WS release", () => { + // Confirm reservation was applied on init + expect(card.classList.contains("sig-reserved--own")).toBe(true); + expect(card.classList.contains("sig-reserved")).toBe(true); + + // Tab A presses NVM — tab B receives this WS event with the card_id + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "PC", reserved: false }, + })); + + expect(card.classList.contains("sig-reserved--own")).toBe(false); + expect(card.classList.contains("sig-reserved")).toBe(false); + }); + + it("unfreezes the stage so other cards can be focused after WS release", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "PC", reserved: false }, + })); + + // Should now be able to click the card body again + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + }); + }); }); diff --git a/src/static_src/scss/_room.scss b/src/static_src/scss/_room.scss index 23ad486..be0e8e8 100644 --- a/src/static_src/scss/_room.scss +++ b/src/static_src/scss/_room.scss @@ -868,7 +868,7 @@ html:has(.sig-backdrop) { display: flex; flex-direction: row; align-items: flex-end; - padding: 0.75rem; + padding-left: 1.5rem; gap: 0.75rem; // Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height. @@ -927,10 +927,12 @@ html:has(.sig-backdrop) { } } - // Stat block — hidden until a card is previewed; fills remaining stage width. + // Stat block — same height as the preview card; fills remaining stage width. + // Height derived from the JS-computed --sig-card-w via aspect ratio 5:8. .sig-stat-block { flex: 1; - align-self: stretch; + height: calc(var(--sig-card-w, 120px) * 8 / 5); + align-self: flex-end; background: rgba(var(--priUser), 0.25); border-radius: 0.4rem; border: 0.1rem solid rgba(var(--terUser), 0.15); diff --git a/src/static_src/tests/SigSelectSpec.js b/src/static_src/tests/SigSelectSpec.js index 61f376f..1eb928f 100644 --- a/src/static_src/tests/SigSelectSpec.js +++ b/src/static_src/tests/SigSelectSpec.js @@ -212,4 +212,37 @@ describe("SigSelect", () => { expect(card.classList.contains("sig-focused")).toBe(true); }); }); + + // ── WS release clears NVM in a second browser ─────────────────────── // + // Simulates the same gamer having two tabs open: tab B must clear its + // .sig-reserved--own when tab A presses NVM (WS release event arrives). + // The release payload must carry the card_id so the JS can find the element. + + describe("WS release event (second-browser NVM sync)", () => { + beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' })); + + it("removes .sig-reserved and .sig-reserved--own on WS release", () => { + // Confirm reservation was applied on init + expect(card.classList.contains("sig-reserved--own")).toBe(true); + expect(card.classList.contains("sig-reserved")).toBe(true); + + // Tab A presses NVM — tab B receives this WS event with the card_id + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "PC", reserved: false }, + })); + + expect(card.classList.contains("sig-reserved--own")).toBe(false); + expect(card.classList.contains("sig-reserved")).toBe(false); + }); + + it("unfreezes the stage so other cards can be focused after WS release", () => { + window.dispatchEvent(new CustomEvent("room:sig_reserved", { + detail: { card_id: 42, role: "PC", reserved: false }, + })); + + // Should now be able to click the card body again + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(card.classList.contains("sig-focused")).toBe(true); + }); + }); });