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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user