dubbodeck: assemble each sig pile per-segment from the contributing seat's own deck (cross-deck), not one shared deck — TDD
Each polarity's 18-card sig pile now assembles from THREE seats by segment, each FROM THAT SEAT'S OWN deck: CROWNS/BRANDS courts (8) from the cb role's deck, GRAILS/BLADES courts (8) from the gb role's, majors 0/1 (2) from the tr role's — levity cb/gb/tr = PC/SC/NC, gravity = BC/AC/EC. So an RWS King of Grails (SC seat) can sit beside a Minchiate Queen of Wands (PC seat) in one levity pile, each carrying its own deck_variant -> per-card face/back art, NO schema change (cards already carry deck_variant). - models.py: new _POLARITY_SEGMENT_ROLES + _seat_deck_for_role + _court_cards + _major_cards + _polarity_sig_cards. A missing seat/deck falls back to _room_deck_variant, so a single-deck (or CARTE-solo) room assembles the IDENTICAL 18-card pile it always did (16 courts + 2 majors). sig_deck_cards is now the UNION of both polarity piles (note-unfiltered) -> select_sig's pick validation (views.py:1664) accepts a card from EITHER deck/polarity with no view change. Dropped the now-dead _sig_unique_cards; _sig_unique_cards_for_deck stays for personal_sig_cards (my_sign, single equipped deck). - TDD: DubbodeckAssemblyTest (4 ITs) — levity grails/blades come from the SC seat's distinct deck while crowns/brands stay earthman; the gravity pile is unaffected by a levity-seat deck; the validation set spans both decks; CARTE-solo one-deck feeds BOTH polarity piles sharing pks (no cross-polarity dedup). Existing SigDeckCompositionTest (36/16/16/4) + SigCardHelperTest (single-deck counts, note unlocks, share-pks, empty fallback) green — single-deck behavior preserved. 736 epic+drama ITs green. - bundled (parallel work): rootvars.scss ongoing palette tuning. [[project-deck-segment-model]] [[project-image-based-deck-face-rendering]] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -780,16 +780,74 @@ def _room_deck_variant(room):
|
||||
return room.owner.equipped_deck
|
||||
|
||||
|
||||
def sig_deck_cards(room):
|
||||
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||
# ── Dubbodeck assembly ────────────────────────────────────────────────────
|
||||
# Each polarity's 18-card sig pile is assembled from THREE seats, each
|
||||
# contributing ONE segment FROM THAT SEAT'S OWN deck (user-locked 2026-06-03):
|
||||
# • CROWNS/BRANDS courts (8) ← the `cb` role's seat deck
|
||||
# • GRAILS/BLADES courts (8) ← the `gb` role's seat deck
|
||||
# • TRUMPS majors 0,1 (2) ← the `tr` role's seat deck
|
||||
# so an RWS King of Grails (from an SC seat) can sit beside a Minchiate Queen of
|
||||
# Wands (from a PC seat) in the same levity pile, each rendering its own deck's
|
||||
# face/back art (each card already carries `deck_variant`, so NO schema change).
|
||||
# A missing seat/deck falls back to `_room_deck_variant`, so a single-deck (or
|
||||
# CARTE-solo) room assembles the identical 18-card pile it always did.
|
||||
_POLARITY_SEGMENT_ROLES = {
|
||||
"levity": {"cb": "PC", "gb": "SC", "tr": "NC"},
|
||||
"gravity": {"cb": "BC", "gb": "AC", "tr": "EC"},
|
||||
}
|
||||
|
||||
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.
|
||||
"""
|
||||
unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room))
|
||||
return unique_cards + unique_cards # × 2 = 36
|
||||
|
||||
def _seat_deck_for_role(room, role):
|
||||
"""The deck contributed by the seat holding `role`; falls back to the room
|
||||
deck when that seat (or its deck) is missing (single-deck / CARTE-solo)."""
|
||||
seat = room.table_seats.filter(role=role).first()
|
||||
if seat and seat.deck_variant_id:
|
||||
return seat.deck_variant
|
||||
return _room_deck_variant(room)
|
||||
|
||||
|
||||
def _court_cards(deck_variant, suits):
|
||||
"""Court cards (rank 11–14) of the given suits from a deck. Courts are keyed
|
||||
by RANK, not arcana class — Earthman classes them MIDDLE, RWS/Minchiate MINOR
|
||||
— so both classifications qualify (mirrors `_sig_unique_cards_for_deck`)."""
|
||||
if deck_variant is None:
|
||||
return []
|
||||
return list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
|
||||
suit__in=suits,
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
|
||||
|
||||
def _major_cards(deck_variant):
|
||||
"""The two sig majors (Nomad 0, Schizo 1) from a deck."""
|
||||
if deck_variant is None:
|
||||
return []
|
||||
return list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant, arcana=TarotCard.MAJOR, number__in=[0, 1],
|
||||
))
|
||||
|
||||
|
||||
def _polarity_sig_cards(room, polarity):
|
||||
"""Assemble one polarity's 18-card dubbodeck pile, note-UNFILTERED, in the
|
||||
layout order C/B courts (8) → G/B courts (8) → majors (2)."""
|
||||
roles = _POLARITY_SEGMENT_ROLES[polarity]
|
||||
return (
|
||||
_court_cards(_seat_deck_for_role(room, roles["cb"]),
|
||||
[TarotCard.BRANDS, TarotCard.CROWNS])
|
||||
+ _court_cards(_seat_deck_for_role(room, roles["gb"]),
|
||||
[TarotCard.GRAILS, TarotCard.BLADES])
|
||||
+ _major_cards(_seat_deck_for_role(room, roles["tr"]))
|
||||
)
|
||||
|
||||
|
||||
def sig_deck_cards(room):
|
||||
"""The full sig validation set — BOTH polarity piles (note-UNFILTERED), so
|
||||
`select_sig` accepts a card from EITHER deck/polarity. For a single-deck room
|
||||
every segment resolves to the same deck, so this is 18 + 18 = 36 (each pile's
|
||||
18 doubled across the two polarities) with the historic suit/arcana split."""
|
||||
return _polarity_sig_cards(room, "levity") + _polarity_sig_cards(room, "gravity")
|
||||
|
||||
|
||||
def _sig_unique_cards_for_deck(deck_variant):
|
||||
@@ -824,11 +882,6 @@ def _sig_unique_cards_for_deck(deck_variant):
|
||||
return courts + major
|
||||
|
||||
|
||||
def _sig_unique_cards(room):
|
||||
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||
return _sig_unique_cards_for_deck(_room_deck_variant(room))
|
||||
|
||||
|
||||
def personal_sig_cards(user):
|
||||
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
|
||||
User.equipped_deck instead of room.deck_variant. For the Game Sign
|
||||
@@ -859,13 +912,15 @@ def _filter_major_unlocks(cards, user):
|
||||
|
||||
|
||||
def levity_sig_cards(room, user=None):
|
||||
"""Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks."""
|
||||
return _filter_major_unlocks(_sig_unique_cards(room), user)
|
||||
"""The levity dubbodeck pile (PC crowns/brands · SC grails/blades · NC
|
||||
majors), filtered by the user's Note unlocks (Nomad/Schizo majors)."""
|
||||
return _filter_major_unlocks(_polarity_sig_cards(room, "levity"), user)
|
||||
|
||||
|
||||
def gravity_sig_cards(room, user=None):
|
||||
"""Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks."""
|
||||
return _filter_major_unlocks(_sig_unique_cards(room), user)
|
||||
"""The gravity dubbodeck pile (BC crowns/brands · AC grails/blades · EC
|
||||
majors), filtered by the user's Note unlocks (Nomad/Schizo majors)."""
|
||||
return _filter_major_unlocks(_polarity_sig_cards(room, "gravity"), user)
|
||||
|
||||
|
||||
def sig_seat_order(room):
|
||||
|
||||
@@ -768,6 +768,63 @@ class SigCardHelperTest(TestCase):
|
||||
self.assertEqual(gravity_sig_cards(self.room, self.owner), [])
|
||||
|
||||
|
||||
class DubbodeckAssemblyTest(TestCase):
|
||||
"""Cross-deck sig pile assembly — each polarity's pile draws its segments
|
||||
from the CONTRIBUTING seat's own deck (the user-locked grid: cb=PC/BC
|
||||
crowns/brands · gb=SC/AC grails/blades · tr=NC/EC majors). Single-deck +
|
||||
note-unlock + empty-fallback are covered by SigCardHelperTest; here the
|
||||
polarity seats hold DISTINCT decks so the piles diverge."""
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman = _full_sig_room()
|
||||
# A second deck carrying only the GRAILS/BLADES courts (the gb segment).
|
||||
self.rws = DeckVariant.objects.create(
|
||||
slug="rws-test", name="RWS Test", card_count=78)
|
||||
for suit in (TarotCard.GRAILS, TarotCard.BLADES):
|
||||
for n in (11, 12, 13, 14):
|
||||
TarotCard.objects.create(
|
||||
deck_variant=self.rws, arcana=TarotCard.MINOR,
|
||||
suit=suit, number=n, slug=f"rws-{suit}-{n}".lower())
|
||||
# Every seat on earthman, then override SC (levity gb segment) → rws.
|
||||
self.room.table_seats.update(deck_variant=self.earthman)
|
||||
self.room.table_seats.filter(role="SC").update(deck_variant=self.rws)
|
||||
|
||||
def test_levity_grails_blades_segment_comes_from_the_sc_seat_deck(self):
|
||||
levity = levity_sig_cards(self.room) # user=None → 16 courts
|
||||
gb = [c for c in levity if c.suit in ("GRAILS", "BLADES")]
|
||||
cb = [c for c in levity if c.suit in ("BRANDS", "CROWNS")]
|
||||
self.assertEqual(len(gb), 8)
|
||||
self.assertEqual(len(cb), 8)
|
||||
# Cross-deck: grails/blades from rws (SC seat), crowns/brands from
|
||||
# earthman (PC seat) — the same pile holds cards from two decks.
|
||||
self.assertTrue(all(c.deck_variant_id == self.rws.id for c in gb))
|
||||
self.assertTrue(all(c.deck_variant_id == self.earthman.id for c in cb))
|
||||
|
||||
def test_gravity_pile_unaffected_by_a_levity_seat_deck(self):
|
||||
# SC is a levity role; gravity's grails/blades come from AC (earthman),
|
||||
# so the gravity pile stays all-earthman.
|
||||
gravity = gravity_sig_cards(self.room)
|
||||
self.assertTrue(gravity)
|
||||
self.assertTrue(all(c.deck_variant_id == self.earthman.id for c in gravity))
|
||||
|
||||
def test_select_sig_validation_set_spans_both_decks(self):
|
||||
# sig_deck_cards drives select_sig's pick validation — it must include
|
||||
# the rws grails/blades courts, else a valid mixed-pile pick would 400.
|
||||
ids = {c.pk for c in sig_deck_cards(self.room)}
|
||||
rws_court = TarotCard.objects.filter(deck_variant=self.rws).first()
|
||||
self.assertIn(rws_court.pk, ids)
|
||||
|
||||
def test_carte_solo_one_deck_feeds_both_polarity_piles(self):
|
||||
# The CARTE-solo rule: one user/deck across all seats → the SAME
|
||||
# grails/blades cards legitimately populate BOTH piles (no cross-polarity
|
||||
# dedup). levity & gravity share pks.
|
||||
self.room.table_seats.update(deck_variant=self.earthman)
|
||||
levity = levity_sig_cards(self.room)
|
||||
gravity = gravity_sig_cards(self.room)
|
||||
self.assertTrue(levity and gravity)
|
||||
self.assertEqual(sorted(c.pk for c in levity), sorted(c.pk for c in gravity))
|
||||
|
||||
|
||||
class PersonalSigCardsTest(TestCase):
|
||||
"""personal_sig_cards(user) — solo (room-less) sig pile sourced from
|
||||
User.equipped_deck. Same 18-card pile + Note-unlock filtering as
|
||||
|
||||
@@ -296,7 +296,7 @@
|
||||
--terMrb: 115, 116, 117;
|
||||
--quaMrb: 85, 86, 87;
|
||||
--quiMrb: 55, 56, 57;
|
||||
--sixMrb: 25, 26, 27;
|
||||
--sixMrb: 35, 36, 37;
|
||||
// flaming porphyry (Satisfaction)
|
||||
--priPhy: 250, 105, 116;
|
||||
--secPhy: 200, 85, 92;
|
||||
@@ -306,7 +306,7 @@
|
||||
--sixPhy: 66, 20, 32;
|
||||
// threshold of adamant (Absolution)
|
||||
--priAdm: 35, 40, 43;
|
||||
--secAdm: 75, 81, 84;
|
||||
--secAdm: 65, 71, 74;
|
||||
--terAdm: 119, 131, 135;
|
||||
--quaAdm: 164, 180, 186;
|
||||
--quiAdm: 197, 213, 228;
|
||||
@@ -329,7 +329,7 @@
|
||||
--priBfp: 255, 92, 43;
|
||||
// Animal Bundle
|
||||
// • amber (clear honey)
|
||||
--priClh: 238, 160, 70;
|
||||
--priClh: 218, 145, 60;
|
||||
--secClh: 255, 216, 171;
|
||||
// • pink (common)
|
||||
--terClh: 238, 70, 148;
|
||||
@@ -347,7 +347,7 @@
|
||||
/* Lord Baltimore Hues */
|
||||
// yellow
|
||||
--priBlt: 235, 191, 0;
|
||||
--secBlt: 187, 147, 52;
|
||||
--secBlt: 137, 107, 32;
|
||||
// white
|
||||
--terBlt: 255, 255, 255;
|
||||
// --quaBlt: ;
|
||||
@@ -359,7 +359,7 @@
|
||||
--octBlt: 157, 34, 53;
|
||||
// orange
|
||||
--ninBlt: 221, 73, 38;
|
||||
// --decBlt: ;
|
||||
--decBlt: 181, 57, 30;
|
||||
|
||||
// Felt values
|
||||
--undUser: var(--priFor);
|
||||
@@ -420,16 +420,16 @@
|
||||
}
|
||||
/* Torre Terrestre Palette */
|
||||
.palette-terrestre {
|
||||
--priUser: var(--priAdm);
|
||||
--priUser: var(--sixMrb);
|
||||
--secUser: var(--quaAdm);
|
||||
--terUser: var(--sixAdm);
|
||||
--quaUser: var(--priPhy);
|
||||
--quiUser: var(--quiPhy);
|
||||
--sixUser: var(--terPer);
|
||||
--sepUser: var(--quaMrb);
|
||||
--sepUser: var(--secAdm);
|
||||
--octUser: var(--priPer);
|
||||
--ninUser: var(--sixPer);
|
||||
--decUser: var(--terMrb);
|
||||
--decUser: var(--priMrb);
|
||||
}
|
||||
/* Fantastia Celestia Palette */
|
||||
.palette-celestia {
|
||||
@@ -438,8 +438,8 @@
|
||||
--terUser: var(--terClh);
|
||||
--quaUser: var(--decClh);
|
||||
--quiUser: var(--ninClh);
|
||||
--sixUser: var(--sepClh);
|
||||
--sepUser: var(--priClh);
|
||||
--sixUser: var(--priClh);
|
||||
--sepUser: var(--sepClh);
|
||||
--octUser: var(--quaClh);
|
||||
--ninUser: var(--secClh);
|
||||
--decUser: var(--quiClh);
|
||||
@@ -454,10 +454,10 @@
|
||||
--secUser: var(--sixBlt);
|
||||
--terUser: var(--ninBlt);
|
||||
--quaUser: var(--priBlt);
|
||||
--quiUser: var(--terMze);
|
||||
--sixUser: var(--quiBlt);
|
||||
--quiUser: var(--secBlt);
|
||||
--sixUser: var(--ninBlt);
|
||||
--sepUser: var(--quiBlt);
|
||||
--octUser: var(--quiBlt);
|
||||
--octUser: var(--decBlt);
|
||||
--ninUser: var(--terBlt);
|
||||
--decUser: var(--quiBlt);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user