sea select scroll log: publish a Role<->Celtic-position affinity on the completing draw; redact + relinquish on DEL; re-publish on re-draw — TDD

The gameroom DRAW SEA phase now writes drama provenance, mirroring sig/sky. When a gamer's 6-card Celtic Cross COMPLETES, a SEA_DRAWN Scroll log publishes their affinity with the card sitting in their Role-correlated position.

- epic/views.py: ROLE_POSITION_MAP — the user's sixfold index (PC->crown, NC->leave, EC->loom, SC->cover, AC->cross, BC->lay; roles rotate each round, so a seat's CURRENT role drives it) + SEA_POSITION_LABELS (each spread's display label for a position KEY; Waite-Smith's Behind/Before/Beneath + Escape-Velocity's Leave/Loom/Lay both key to the same index). sea_save publishes SEA_DRAWN on the <6->6 completing transition only (a reload that re-POSTs the full hand can't double-publish); a re-draw first redacts the standing relinquishment, then publishes anew. sea_delete redacts the published affinity (the strikethrough) + records SEA_RELINQUISHED in its wake (the redact-pair). _sea_affinity_for mirrors SIG_READY's polarity-qualified name_title + corner abbrev; _redact_standing_sea_event tests 'not retracted' in PYTHON (the SQLite JSONField exclude-NULL trap).

- drama/models.py: SEA_DRAWN + SEA_RELINQUISHED verbs + to_prose ('draws {poss} Celtic Cross, finding affinity with the {card}{abbrev} in the {Position}.' / 'relinquishes {poss} affinity with the {card}.'); 'The ' stripped so a levity/gravity qualifier butts the proper name. The generic struck/retracted property renders the strikethrough + data-label=redact in _scroll.html unchanged.

TDD: 4 drama prose ITs (affinity statement, spread-label passthrough, relinquishment, struck-when-retracted) + 7 epic ITs (publish-on-complete, position=crown for PC, none-before-complete, no-double-publish-on-reload, DEL redacts+relinquishes, re-draw redacts-the-relinquishment+republishes, DEL-noop-when-nothing-published). 459 drama + epic-view ITs green.

- bundled (parallel work): rootvars.scss --sixUser/--sepUser/--octUser slot reassignments across the forest/khaki/blade palettes (tuning the new reelhouse h2 bgs) + a new --terMrb.

[[project-sea-select-scroll-provenance]] [[feedback-jsonfield-exclude-sqlite-null]]

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-06-08 19:35:45 -04:00
parent 039152a787
commit d28046f3da
5 changed files with 228 additions and 9 deletions

View File

@@ -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' <i class="fa-solid {suit_icon}"></i>' 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

View File

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

View File

@@ -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

View File

@@ -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})

View File

@@ -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);
}