many deck changes, including pentacles to crowns, middle arcana, and major arcana fa icons
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user