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:
Disco DeDisco
2026-06-08 19:53:45 -04:00
parent 6f1729010f
commit b4ffab186e
3 changed files with 143 additions and 31 deletions

View File

@@ -780,16 +780,74 @@ def _room_deck_variant(room):
return room.owner.equipped_deck return room.owner.equipped_deck
def sig_deck_cards(room): # ── Dubbodeck assembly ────────────────────────────────────────────────────
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2). # 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 (1114): 8 unique
SC/AC pair → BLADES + GRAILS Middle Arcana court cards (1114): 8 unique def _seat_deck_for_role(room, role):
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique """The deck contributed by the seat holding `role`; falls back to the room
Total: 18 unique × 2 (levity + gravity piles) = 36 cards. deck when that seat (or its deck) is missing (single-deck / CARTE-solo)."""
""" seat = room.table_seats.filter(role=role).first()
unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room)) if seat and seat.deck_variant_id:
return unique_cards + unique_cards # × 2 = 36 return seat.deck_variant
return _room_deck_variant(room)
def _court_cards(deck_variant, suits):
"""Court cards (rank 1114) 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): def _sig_unique_cards_for_deck(deck_variant):
@@ -824,11 +882,6 @@ def _sig_unique_cards_for_deck(deck_variant):
return courts + major 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): def personal_sig_cards(user):
"""Solo equivalent of levity_sig_cards / gravity_sig_cards — uses """Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
User.equipped_deck instead of room.deck_variant. For the Game Sign 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): def levity_sig_cards(room, user=None):
"""Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks.""" """The levity dubbodeck pile (PC crowns/brands · SC grails/blades · NC
return _filter_major_unlocks(_sig_unique_cards(room), user) 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): def gravity_sig_cards(room, user=None):
"""Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks.""" """The gravity dubbodeck pile (BC crowns/brands · AC grails/blades · EC
return _filter_major_unlocks(_sig_unique_cards(room), user) 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): def sig_seat_order(room):

View File

@@ -768,6 +768,63 @@ class SigCardHelperTest(TestCase):
self.assertEqual(gravity_sig_cards(self.room, self.owner), []) 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): class PersonalSigCardsTest(TestCase):
"""personal_sig_cards(user) — solo (room-less) sig pile sourced from """personal_sig_cards(user) — solo (room-less) sig pile sourced from
User.equipped_deck. Same 18-card pile + Note-unlock filtering as User.equipped_deck. Same 18-card pile + Note-unlock filtering as

View File

@@ -296,7 +296,7 @@
--terMrb: 115, 116, 117; --terMrb: 115, 116, 117;
--quaMrb: 85, 86, 87; --quaMrb: 85, 86, 87;
--quiMrb: 55, 56, 57; --quiMrb: 55, 56, 57;
--sixMrb: 25, 26, 27; --sixMrb: 35, 36, 37;
// flaming porphyry (Satisfaction) // flaming porphyry (Satisfaction)
--priPhy: 250, 105, 116; --priPhy: 250, 105, 116;
--secPhy: 200, 85, 92; --secPhy: 200, 85, 92;
@@ -306,7 +306,7 @@
--sixPhy: 66, 20, 32; --sixPhy: 66, 20, 32;
// threshold of adamant (Absolution) // threshold of adamant (Absolution)
--priAdm: 35, 40, 43; --priAdm: 35, 40, 43;
--secAdm: 75, 81, 84; --secAdm: 65, 71, 74;
--terAdm: 119, 131, 135; --terAdm: 119, 131, 135;
--quaAdm: 164, 180, 186; --quaAdm: 164, 180, 186;
--quiAdm: 197, 213, 228; --quiAdm: 197, 213, 228;
@@ -329,7 +329,7 @@
--priBfp: 255, 92, 43; --priBfp: 255, 92, 43;
// Animal Bundle // Animal Bundle
// • amber (clear honey) // • amber (clear honey)
--priClh: 238, 160, 70; --priClh: 218, 145, 60;
--secClh: 255, 216, 171; --secClh: 255, 216, 171;
// • pink (common) // • pink (common)
--terClh: 238, 70, 148; --terClh: 238, 70, 148;
@@ -347,7 +347,7 @@
/* Lord Baltimore Hues */ /* Lord Baltimore Hues */
// yellow // yellow
--priBlt: 235, 191, 0; --priBlt: 235, 191, 0;
--secBlt: 187, 147, 52; --secBlt: 137, 107, 32;
// white // white
--terBlt: 255, 255, 255; --terBlt: 255, 255, 255;
// --quaBlt: ; // --quaBlt: ;
@@ -359,7 +359,7 @@
--octBlt: 157, 34, 53; --octBlt: 157, 34, 53;
// orange // orange
--ninBlt: 221, 73, 38; --ninBlt: 221, 73, 38;
// --decBlt: ; --decBlt: 181, 57, 30;
// Felt values // Felt values
--undUser: var(--priFor); --undUser: var(--priFor);
@@ -420,16 +420,16 @@
} }
/* Torre Terrestre Palette */ /* Torre Terrestre Palette */
.palette-terrestre { .palette-terrestre {
--priUser: var(--priAdm); --priUser: var(--sixMrb);
--secUser: var(--quaAdm); --secUser: var(--quaAdm);
--terUser: var(--sixAdm); --terUser: var(--sixAdm);
--quaUser: var(--priPhy); --quaUser: var(--priPhy);
--quiUser: var(--quiPhy); --quiUser: var(--quiPhy);
--sixUser: var(--terPer); --sixUser: var(--terPer);
--sepUser: var(--quaMrb); --sepUser: var(--secAdm);
--octUser: var(--priPer); --octUser: var(--priPer);
--ninUser: var(--sixPer); --ninUser: var(--sixPer);
--decUser: var(--terMrb); --decUser: var(--priMrb);
} }
/* Fantastia Celestia Palette */ /* Fantastia Celestia Palette */
.palette-celestia { .palette-celestia {
@@ -438,8 +438,8 @@
--terUser: var(--terClh); --terUser: var(--terClh);
--quaUser: var(--decClh); --quaUser: var(--decClh);
--quiUser: var(--ninClh); --quiUser: var(--ninClh);
--sixUser: var(--sepClh); --sixUser: var(--priClh);
--sepUser: var(--priClh); --sepUser: var(--sepClh);
--octUser: var(--quaClh); --octUser: var(--quaClh);
--ninUser: var(--secClh); --ninUser: var(--secClh);
--decUser: var(--quiClh); --decUser: var(--quiClh);
@@ -454,10 +454,10 @@
--secUser: var(--sixBlt); --secUser: var(--sixBlt);
--terUser: var(--ninBlt); --terUser: var(--ninBlt);
--quaUser: var(--priBlt); --quaUser: var(--priBlt);
--quiUser: var(--terMze); --quiUser: var(--secBlt);
--sixUser: var(--quiBlt); --sixUser: var(--ninBlt);
--sepUser: var(--quiBlt); --sepUser: var(--quiBlt);
--octUser: var(--quiBlt); --octUser: var(--decBlt);
--ninUser: var(--terBlt); --ninUser: var(--terBlt);
--decUser: var(--quiBlt); --decUser: var(--quiBlt);
} }