diff --git a/src/apps/drama/models.py b/src/apps/drama/models.py index 1a5e617..ba8d732 100644 --- a/src/apps/drama/models.py +++ b/src/apps/drama/models.py @@ -26,6 +26,12 @@ class GameEvent(models.Model): SIG_UNREADY = "sig_unready" # Sky Select phase SKY_SAVED = "sky_saved" + # Sea Select phase — Role↔Celtic-position affinity provenance. SEA_DRAWN + # publishes on hand-complete; SEA_RELINQUISHED is the let-go entry left in + # the redact-pair's wake when the spread is DELeted (the SEA_DRAWN gets + # `retracted` + a re-draw later retracts the relinquishment in turn). + SEA_DRAWN = "sea_drawn" + SEA_RELINQUISHED = "sea_relinquished" VERB_CHOICES = [ (ROOM_CREATED, "Room created"), @@ -40,6 +46,8 @@ class GameEvent(models.Model): (SIG_READY, "Sig claim staked"), (SIG_UNREADY, "Sig claim withdrawn"), (SKY_SAVED, "Sky saved"), + (SEA_DRAWN, "Sea drawn"), + (SEA_RELINQUISHED, "Sea relinquished"), ] room = models.ForeignKey( @@ -154,6 +162,31 @@ class GameEvent(models.Model): f"beholds the skyscape of {poss} birth, " f"which yields {obj} equal {joined} capacities." ) + if self.verb == self.SEA_DRAWN: + # Affinity statement: the card drawn into the gamer's Role-correlated + # Celtic position. `card_name`/`corner_rank`/`suit_icon` mirror + # SIG_READY (qualifier-prefixed name + abbrev, "The " stripped so a + # qualifier butts the proper name); `position_label` is the + # spread-specific label for the role's position key. + card_name = d.get("card_name", "a card") + corner_rank = d.get("corner_rank", "") + suit_icon = d.get("suit_icon", "") + position_label = d.get("position_label", "spread") + if corner_rank: + icon_html = f' ' if suit_icon else "" + abbrev = f" ({corner_rank}{icon_html})" + else: + abbrev = "" + card_name = card_name.replace("The ", "", 1) + _, _, poss = _actor_pronouns(self.actor) + return ( + f"draws {poss} Celtic Cross, finding affinity with " + f"the {card_name}{abbrev} in the {position_label}." + ) + if self.verb == self.SEA_RELINQUISHED: + card_name = d.get("card_name", "a card").replace("The ", "", 1) + _, _, poss = _actor_pronouns(self.actor) + return f"relinquishes {poss} affinity with the {card_name}." return self.verb @property diff --git a/src/apps/drama/tests/integrated/test_models.py b/src/apps/drama/tests/integrated/test_models.py index 8c13dcb..d694a7c 100644 --- a/src/apps/drama/tests/integrated/test_models.py +++ b/src/apps/drama/tests/integrated/test_models.py @@ -180,6 +180,37 @@ class GameEventModelTest(TestCase): event.to_prose(), ) + # ── to_prose — SEA_DRAWN / SEA_RELINQUISHED ────────────────────────── + def test_sea_drawn_prose_states_affinity_and_position(self): + event = record(self.room, GameEvent.SEA_DRAWN, actor=self.user, + role="PC", slot_number=1, position="crown", + position_label="Crown", card_name="The Magician", + corner_rank="I", suit_icon="") + prose = event.to_prose() + # Default pronouns = pluralism → "their"; "The " is stripped before "the". + self.assertIn( + "draws their Celtic Cross, finding affinity with the Magician (I) " + "in the Crown.", prose) + + def test_sea_drawn_prose_uses_spread_label(self): + # Escape-Velocity calls the `loom` position "Loom"; the label is passed + # in `position_label`, so to_prose stays spread-agnostic. + event = record(self.room, GameEvent.SEA_DRAWN, actor=self.user, + role="EC", slot_number=3, position="loom", + position_label="Loom", card_name="Maid of Brands") + self.assertIn("in the Loom.", event.to_prose()) + + def test_sea_relinquished_prose(self): + event = record(self.room, GameEvent.SEA_RELINQUISHED, actor=self.user, + role="PC", slot_number=1, card_name="The Magician") + self.assertIn( + "relinquishes their affinity with the Magician.", event.to_prose()) + + def test_sea_drawn_struck_when_retracted(self): + event = record(self.room, GameEvent.SEA_DRAWN, actor=self.user, + slot_number=1, card_name="The Magician", retracted=True) + self.assertTrue(event.struck) + def test_sig_ready_prose_degrades_without_corner_rank(self): # Old events recorded before this change have no corner_rank key event = record(self.room, GameEvent.SIG_READY, actor=self.user, diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index a727a31..8662afe 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -4266,6 +4266,75 @@ class PickSeaPersistTest(TestCase): self.assertIn("cover", saved) self.assertEqual(saved["cover"]["card_id"], hand[0]["card_id"]) + # ── Sea Select Scroll-log provenance (affinity / redact / re-publish) ── + # PC's Role-correlated Celtic position is CROWN (the sixfold map); _hand() + # places a card at "crown", so the PC gamer's affinity is that card. + def _post_hand(self, hand=None, spread="waite-smith"): + return self.client.post( + self.save_url, + data={"spread": spread, "hand": self._hand() if hand is None else hand}, + content_type="application/json", + ) + + def _drawn(self, struck=None): + evs = list(self.room.events.filter(verb=GameEvent.SEA_DRAWN)) + if struck is True: + return [e for e in evs if e.struck] + if struck is False: + return [e for e in evs if not e.struck] + return evs + + def test_completing_save_publishes_affinity_scroll_log(self): + hand = self._hand() + self._post_hand(hand) + drawn = self._drawn() + self.assertEqual(len(drawn), 1) + ev = drawn[0] + self.assertEqual(ev.data["position"], "crown") # PC → crown + crown_id = next(h["card_id"] for h in hand if h["position"] == "crown") + crown_card = TarotCard.objects.get(id=crown_id) + self.assertIn(crown_card.name_title, ev.data["card_name"]) + + def test_affinity_prose_names_the_waite_smith_position_label(self): + self._post_hand() + prose = self._drawn()[0].to_prose() + self.assertIn("finding affinity with", prose) + self.assertIn("in the Crown.", prose) + + def test_no_affinity_logged_before_hand_complete(self): + self._post_hand(self._hand()[:5]) # only 5 of 6 placed + self.assertEqual(self._drawn(), []) + + def test_reload_resave_does_not_double_publish(self): + hand = self._hand() + self._post_hand(hand) + self._post_hand(hand) # reload re-POSTs the full 6-card hand + self.assertEqual(len(self._drawn()), 1) + + def test_del_redacts_affinity_and_leaves_a_relinquishment(self): + self._post_hand() + self.client.post(self.delete_url) + self.assertEqual(len(self._drawn(struck=True)), 1) # the affinity is struck + self.assertTrue( + self.room.events.filter(verb=GameEvent.SEA_RELINQUISHED).exists()) + + def test_redraw_redacts_the_relinquishment_and_republishes(self): + self._post_hand() + self.client.post(self.delete_url) + self._post_hand() # re-draw completes + relinquish = self.room.events.filter(verb=GameEvent.SEA_RELINQUISHED).first() + self.assertTrue(relinquish.struck) # the standing relinquishment redacted + self.assertEqual(len(self._drawn(struck=False)), 1) # one live affinity again + + def test_del_without_a_published_affinity_is_a_noop(self): + # An incomplete hand never published → DEL records nothing. + self.char.celtic_cross = {"spread": "waite-smith", "hand": self._hand()[:5]} + self.char.save(update_fields=["celtic_cross"]) + self.client.post(self.delete_url) + self.assertEqual(self._drawn(), []) + self.assertFalse( + self.room.events.filter(verb=GameEvent.SEA_RELINQUISHED).exists()) + class PickSeaUnifiedFeltTest(TestCase): """DRAW SEA rebuilt as a --duoUser felt + a Gaussian spread modal, mirroring diff --git a/src/apps/epic/views.py b/src/apps/epic/views.py index e6561ee..eb453d2 100644 --- a/src/apps/epic/views.py +++ b/src/apps/epic/views.py @@ -159,6 +159,26 @@ def _notify_sky_confirmed(room_id, seat_role): SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"} +# Sea Select affinity — each Role is correlated with one Celtic-Cross position +# (the "universal slot term"), keyed by the sixfold chair index (user-spec +# 2026-06-08; roles rotate each round, but a seat's CURRENT role drives this). +# The card drawn into that position is the gamer's affinity (the SEA_DRAWN +# Scroll log). All spread label-sets (incl. Waite-Smith's Behind/Before/Beneath) +# key to the SAME position keys — only the displayed label differs per spread. +ROLE_POSITION_MAP = { + "PC": "crown", "NC": "leave", "EC": "loom", + "SC": "cover", "AC": "cross", "BC": "lay", +} +# Per-spread display label for each position key (mirrors POSITION_LABELS in +# _sea_overlay.html). The position KEY is the canonical index; the label is what +# that spread calls it. +SEA_POSITION_LABELS = { + "waite-smith": {"crown": "Crown", "leave": "Behind", "cover": "Cover", + "cross": "Cross", "loom": "Before", "lay": "Beneath"}, + "escape-velocity": {"crown": "Crown", "leave": "Leave", "cover": "Cover", + "cross": "Cross", "loom": "Loom", "lay": "Lay"}, +} + _SIG_SEAT_ORDERING = Case( *[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)], default=Value(99), @@ -1869,6 +1889,51 @@ def _confirmed_character_for(seat): ).first() +def _sea_affinity_for(seat, spread, hand): + """The affinity payload for the card drawn into this seat's Role-correlated + Celtic position (ROLE_POSITION_MAP), or None when the role has no mapped + position / the position isn't in the hand / the card is missing. Mirrors + SIG_READY's display: a polarity-qualified `name_title` + corner abbrev. + `hand` = [{position, card_id, reversed, polarity}, ...].""" + position = ROLE_POSITION_MAP.get(seat.role) + if position is None: + return None + entry = next((h for h in hand if h.get("position") == position), None) + if entry is None: + return None + card = TarotCard.objects.filter(id=entry.get("card_id")).first() + if card is None: + return None + qual = (card.levity_qualifier if entry.get("polarity") == "levity" + else card.gravity_qualifier) + card_display = f"{qual} {card.name_title}" if qual else card.name_title + label = SEA_POSITION_LABELS.get( + spread, SEA_POSITION_LABELS["waite-smith"]).get(position, position.title()) + return { + "position": position, "position_label": label, + "card_name": card_display, "corner_rank": card.corner_rank, + "suit_icon": card.suit_icon, + } + + +def _redact_standing_sea_event(room, gamer, slot_number, verb): + """Mark the most recent non-struck `verb` Scroll event for this seat + `retracted` (the strikethrough redact); return its card_name (for the mirror + entry) or ''. The `not retracted` test runs in PYTHON — `.exclude( + data__retracted=True)` silently drops rows missing the key on SQLite + [[feedback-jsonfield-exclude-sqlite-null]].""" + candidates = [ + e for e in room.events.filter(verb=verb, actor=gamer) + if e.data.get("slot_number") == slot_number and not e.data.get("retracted") + ] + if not candidates: + return "" + target = candidates[-1] # events Meta-ordered by timestamp → last = newest + target.data["retracted"] = True + target.save(update_fields=["data"]) + return target.data.get("card_name", "") + + @login_required def sea_save(request, room_id): """Upsert the seat's Celtic-Cross spread hand onto its confirmed @@ -1898,8 +1963,20 @@ def sea_save(request, room_id): char = _confirmed_character_for(seat) if char is None: return HttpResponse(status=403) + prior_len = len((char.celtic_cross or {}).get('hand') or []) char.celtic_cross = {'spread': spread, 'hand': hand} char.save(update_fields=['celtic_cross']) + # Publish the affinity Scroll log on the COMPLETING save — the <6→6 + # transition only (so a reload that re-POSTs the full 6-card hand doesn't + # double-publish). A re-draw retracts the standing relinquishment DEL left + # behind, then publishes anew (step 3 of the lifecycle). + if len(hand) >= 6 and prior_len < 6: + affinity = _sea_affinity_for(seat, spread, hand) + if affinity is not None: + _redact_standing_sea_event( + room, request.user, seat.slot_number, GameEvent.SEA_RELINQUISHED) + record(room, GameEvent.SEA_DRAWN, actor=request.user, + role=seat.role, slot_number=seat.slot_number, **affinity) return JsonResponse({'ok': True}) @@ -1918,6 +1995,14 @@ def sea_delete(request, room_id): if char is not None and char.celtic_cross is not None: char.celtic_cross = None char.save(update_fields=['celtic_cross']) + # Redact the published affinity (strikethrough) + leave a relinquishment + # entry in its wake (the redact-pair). No-op when nothing was published + # (an incomplete hand never reached the SEA_DRAWN publish). + card_name = _redact_standing_sea_event( + room, request.user, seat.slot_number, GameEvent.SEA_DRAWN) + if card_name: + record(room, GameEvent.SEA_RELINQUISHED, actor=request.user, + role=seat.role, slot_number=seat.slot_number, card_name=card_name) return JsonResponse({'deleted': True}) diff --git a/src/static_src/scss/rootvars.scss b/src/static_src/scss/rootvars.scss index 2822fe3..078e3e7 100644 --- a/src/static_src/scss/rootvars.scss +++ b/src/static_src/scss/rootvars.scss @@ -293,6 +293,7 @@ // polished marble (Confession) --priMrb: 231, 233, 234; --secMrb: 115, 116, 117; + --terMrb: 55, 56, 57; // flaming porphyry (Satisfaction) --priPhy: 200, 55, 66; --secPhy: 75, 31, 48; @@ -379,8 +380,8 @@ --quaUser: var(--priCfw); --quiUser: var(--terCfw); --sixUser: var(--secId); - --sepUser: var(--quaId); - --octUser: var(--terFs); + --sepUser: var(--terFs); + --octUser: var(--quaId); --ninUser: var(--sixPu); --decUser: var(--terPu); } @@ -391,9 +392,9 @@ --terUser: var(--priCfw); --quaUser: var(--quiAu); --quiUser: var(--secCu); - --sixUser: var(--terKhk); - --sepUser: var(--priKhk); - --octUser: var(--priPer); + --sixUser: var(--secKhk); + --sepUser: var(--terPer); + --octUser: var(--terKhk); --ninUser: var(--sixCu); --decUser: var(--terU); } @@ -404,9 +405,9 @@ --terUser: var(--priBld); --quaUser: var(--priIce); --quiUser: var(--quaIce); - --sixUser: var(--priTrs); - --sepUser: var(--terTrs); - --octUser: var(--terBld); + --sixUser: var(--terTrs); + --sepUser: var(--terBld); + --octUser: var(--quiTrs); --ninUser: var(--priMst); --decUser: var(--terMst); } @@ -419,7 +420,7 @@ --quiUser: var(--secPhy); --sixUser: var(--priMrb); --sepUser: var(--terPer); - --octUser: var(--quaAdm); + --octUser: var(--terMrb); --ninUser: var(--sixPer); --decUser: var(--terMrb); }