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