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

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