many deck changes, including pentacles to crowns, middle arcana, and major arcana fa icons

This commit is contained in:
Disco DeDisco
2026-04-05 22:32:40 -04:00
parent c7370bda03
commit c3ab78cc57
11 changed files with 254 additions and 30 deletions

View File

@@ -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),
),
]

View File

@@ -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),
]

View File

@@ -0,0 +1,62 @@
"""
Data migration: Earthman deck — court cards and major arcana icons.
1. Court cards (numbers 1114, all suits): arcana "MINOR""MIDDLE"
2. Major arcana icons (stored in TarotCard.icon):
0 (Nomad) → fa-hat-cowboy-side
1 (Schizo) → fa-hat-wizard
251 (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),
]

View File

@@ -205,20 +205,24 @@ class DeckVariant(models.Model):
class TarotCard(models.Model): class TarotCard(models.Model):
MAJOR = "MAJOR" MAJOR = "MAJOR"
MINOR = "MINOR" MINOR = "MINOR"
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
ARCANA_CHOICES = [ ARCANA_CHOICES = [
(MAJOR, "Major Arcana"), (MAJOR, "Major Arcana"),
(MINOR, "Minor Arcana"), (MINOR, "Minor Arcana"),
(MIDDLE, "Middle Arcana"),
] ]
WANDS = "WANDS" WANDS = "WANDS"
CUPS = "CUPS" CUPS = "CUPS"
SWORDS = "SWORDS" SWORDS = "SWORDS"
PENTACLES = "PENTACLES" # Fiorentine 4th suit PENTACLES = "PENTACLES" # Fiorentine 4th suit
CROWNS = "CROWNS" # Earthman 4th suit (renamed from Pentacles)
SUIT_CHOICES = [ SUIT_CHOICES = [
(WANDS, "Wands"), (WANDS, "Wands"),
(CUPS, "Cups"), (CUPS, "Cups"),
(SWORDS, "Swords"), (SWORDS, "Swords"),
(PENTACLES, "Pentacles"), (PENTACLES, "Pentacles"),
(CROWNS, "Crowns"),
] ]
deck_variant = models.ForeignKey( deck_variant = models.ForeignKey(
@@ -226,8 +230,9 @@ class TarotCard(models.Model):
on_delete=models.CASCADE, related_name="cards", on_delete=models.CASCADE, related_name="cards",
) )
name = models.CharField(max_length=200) 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) 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() # 021 major (Fiorentine); 051 major (Earthman); 114 minor number = models.IntegerField() # 021 major (Fiorentine); 051 major (Earthman); 114 minor
slug = models.SlugField(max_length=120) slug = models.SlugField(max_length=120)
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
@@ -275,6 +280,8 @@ class TarotCard(models.Model):
@property @property
def suit_icon(self): def suit_icon(self):
if self.icon:
return self.icon
if self.arcana == self.MAJOR: if self.arcana == self.MAJOR:
return '' return ''
return { return {
@@ -282,6 +289,7 @@ class TarotCard(models.Model):
self.CUPS: 'fa-trophy', self.CUPS: 'fa-trophy',
self.SWORDS: 'fa-gun', self.SWORDS: 'fa-gun',
self.PENTACLES: 'fa-star', self.PENTACLES: 'fa-star',
self.CROWNS: 'fa-crown',
}.get(self.suit, '') }.get(self.suit, '')
def __str__(self): def __str__(self):
@@ -361,23 +369,23 @@ class SigReservation(models.Model):
def sig_deck_cards(room): def sig_deck_cards(room):
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2). """Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
PC/BC pair → WANDS + PENTACLES court cards (numbers 1114): 8 unique PC/BC pair → WANDS + CROWNS Middle Arcana court cards (1114): 8 unique
SC/AC pair → SWORDS + CUPS court cards (numbers 1114): 8 unique SC/AC pair → SWORDS + CUPS Middle Arcana court cards (1114): 8 unique
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
Total: 18 unique × 2 (levity + gravity piles) = 36 cards. Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
""" """
deck_variant = room.owner.equipped_deck deck_variant = room.owner.equipped_deck
if deck_variant is None: if deck_variant is None:
return [] return []
wands_pentacles = list(TarotCard.objects.filter( wands_crowns = list(TarotCard.objects.filter(
deck_variant=deck_variant, deck_variant=deck_variant,
arcana=TarotCard.MINOR, arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], suit__in=[TarotCard.WANDS, TarotCard.CROWNS],
number__in=[11, 12, 13, 14], number__in=[11, 12, 13, 14],
)) ))
swords_cups = list(TarotCard.objects.filter( swords_cups = list(TarotCard.objects.filter(
deck_variant=deck_variant, deck_variant=deck_variant,
arcana=TarotCard.MINOR, arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.SWORDS, TarotCard.CUPS], suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
number__in=[11, 12, 13, 14], number__in=[11, 12, 13, 14],
)) ))
@@ -386,7 +394,7 @@ def sig_deck_cards(room):
arcana=TarotCard.MAJOR, arcana=TarotCard.MAJOR,
number__in=[0, 1], 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 return unique_cards + unique_cards # × 2 = 36
@@ -395,15 +403,15 @@ def _sig_unique_cards(room):
deck_variant = room.owner.equipped_deck deck_variant = room.owner.equipped_deck
if deck_variant is None: if deck_variant is None:
return [] return []
wands_pentacles = list(TarotCard.objects.filter( wands_crowns = list(TarotCard.objects.filter(
deck_variant=deck_variant, deck_variant=deck_variant,
arcana=TarotCard.MINOR, arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES], suit__in=[TarotCard.WANDS, TarotCard.CROWNS],
number__in=[11, 12, 13, 14], number__in=[11, 12, 13, 14],
)) ))
swords_cups = list(TarotCard.objects.filter( swords_cups = list(TarotCard.objects.filter(
deck_variant=deck_variant, deck_variant=deck_variant,
arcana=TarotCard.MINOR, arcana=TarotCard.MIDDLE,
suit__in=[TarotCard.SWORDS, TarotCard.CUPS], suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
number__in=[11, 12, 13, 14], number__in=[11, 12, 13, 14],
)) ))
@@ -412,7 +420,7 @@ def _sig_unique_cards(room):
arcana=TarotCard.MAJOR, arcana=TarotCard.MAJOR,
number__in=[0, 1], number__in=[0, 1],
)) ))
return wands_pentacles + swords_cups + major return wands_crowns + swords_cups + major
def levity_sig_cards(room): def levity_sig_cards(room):

View File

@@ -276,9 +276,9 @@ class SigDeckCompositionTest(TestCase):
self.assertEqual(len(sc_ac), 16) self.assertEqual(len(sc_ac), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac)) 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) 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.assertEqual(len(pc_bc), 16)
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc)) 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}, defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
) )
self.card = TarotCard.objects.get( 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") owner = User.objects.create(email="owner@test.io")
room = Room.objects.create(name="Field Test", owner=owner) room = Room.objects.create(name="Field Test", owner=owner)

View File

@@ -926,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None):
room.table_status = Room.SIG_SELECT room.table_status = Room.SIG_SELECT
room.save() room.save()
card_in_deck = TarotCard.objects.get( 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) test_case.client.force_login(founder)
return room, gamers, earthman, card_in_deck return room, gamers, earthman, card_in_deck
@@ -1188,7 +1188,7 @@ class SigReserveViewTest(TestCase):
def test_reserve_different_card_while_holding_returns_409(self): def test_reserve_different_card_while_holding_returns_409(self):
"""Cannot OK a different card while holding one — must NVM first.""" """Cannot OK a different card while holding one — must NVM first."""
card_b = TarotCard.objects.filter( 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() ).first()
self._reserve() # PC grabs card A → 200 self._reserve() # PC grabs card A → 200
response = self._reserve(card_id=card_b.id) # tries card B → 409 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): def test_reserve_blocked_then_unblocked_after_release(self):
"""After NVM, a new card can be OK'd.""" """After NVM, a new card can be OK'd."""
card_b = TarotCard.objects.filter( 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() ).first()
self._reserve() # hold card A self._reserve() # hold card A
self._reserve(action="release") # NVM self._reserve(action="release") # NVM
@@ -1270,3 +1270,13 @@ class SigReserveViewTest(TestCase):
with patch("apps.epic.views._notify_sig_reserved") as mock_notify: with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
self._reserve(action="release") self._reserve(action="release")
mock_notify.assert_called_once() 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

View File

@@ -579,8 +579,10 @@ def sig_reserve(request, room_id):
action = request.POST.get("action", "reserve") action = request.POST.get("action", "reserve")
if action == "release": 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() 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) return HttpResponse(status=200)
# Reserve action # Reserve action

View File

@@ -150,7 +150,7 @@ def tarot_fan(request, deck_id):
deck = get_object_or_404(DeckVariant, pk=deck_id) deck = get_object_or_404(DeckVariant, pk=deck_id)
if not request.user.unlocked_decks.filter(pk=deck_id).exists(): if not request.user.unlocked_decks.filter(pk=deck_id).exists():
return HttpResponse(status=403) 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( cards = sorted(
TarotCard.objects.filter(deck_variant=deck), 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), key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),

View File

@@ -212,4 +212,37 @@ describe("SigSelect", () => {
expect(card.classList.contains("sig-focused")).toBe(true); 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);
});
});
}); });

View File

@@ -868,7 +868,7 @@ html:has(.sig-backdrop) {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: flex-end; align-items: flex-end;
padding: 0.75rem; padding-left: 1.5rem;
gap: 0.75rem; gap: 0.75rem;
// Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height. // 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 { .sig-stat-block {
flex: 1; flex: 1;
align-self: stretch; height: calc(var(--sig-card-w, 120px) * 8 / 5);
align-self: flex-end;
background: rgba(var(--priUser), 0.25); background: rgba(var(--priUser), 0.25);
border-radius: 0.4rem; border-radius: 0.4rem;
border: 0.1rem solid rgba(var(--terUser), 0.15); border: 0.1rem solid rgba(var(--terUser), 0.15);

View File

@@ -212,4 +212,37 @@ describe("SigSelect", () => {
expect(card.classList.contains("sig-focused")).toBe(true); 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);
});
});
}); });