Files
python-tdd/src/apps/drama/models.py
Disco DeDisco a6db8c628f sea affinity prose: reinsert the (rank icon) parenthetical + pluralize the NC verb (they/yo leave, he/she/it leaves) — TDD
Two user follow-ups to the personalized affinity prose: (1) the corner-rank + suit-icon abbrev rides the card name again (e.g. 'the Queen of Pentacles (Q <i class=fa-crown></i>)'), mirroring SIG_READY — majors render just the numeral, e.g. '(I)'. (2) The NC clause conjugates the subject verb: they + yo are treated as PLURAL -> 'they leave' / 'yo leave behind'; the singular he/she/it keep the 3rd-person 'leaves'.

TDD: per-role-clauses test updated to 'they leave'; new yo-pluralizes + he-keeps-leaves cases; a with-abbrev PC case asserting the full rank+icon parenthetical. 51 drama+epic ITs green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:23:19 -04:00

437 lines
20 KiB
Python

from django.conf import settings
from django.db import models
from apps.lyric.models import resolve_pronouns
def _actor_pronouns(actor):
"""Return (subj, obj, poss) for an event actor; default = pluralism when None."""
return resolve_pronouns(getattr(actor, "pronouns", None) if actor else None)
class GameEvent(models.Model):
# Gate phase
ROOM_CREATED = "room_created"
SLOT_RESERVED = "slot_reserved"
SLOT_FILLED = "slot_filled"
SLOT_RETURNED = "slot_returned"
SLOT_RELEASED = "slot_released"
INVITE_SENT = "invite_sent"
# Role Select phase
ROLE_SELECT_STARTED = "role_select_started"
ROLE_SELECTED = "role_selected"
ROLES_REVEALED = "roles_revealed"
# Sig Select phase
SIG_READY = "sig_ready"
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"),
(SLOT_RESERVED, "Gate slot reserved"),
(SLOT_FILLED, "Gate slot filled"),
(SLOT_RETURNED, "Gate slot returned"),
(SLOT_RELEASED, "Gate slot released"),
(INVITE_SENT, "Invite sent"),
(ROLE_SELECT_STARTED, "Role select started"),
(ROLE_SELECTED, "Role selected"),
(ROLES_REVEALED, "Roles revealed"),
(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(
"epic.Room", on_delete=models.CASCADE, related_name="events",
)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL, null=True, blank=True,
on_delete=models.SET_NULL, related_name="game_events",
)
verb = models.CharField(max_length=30, choices=VERB_CHOICES)
data = models.JSONField(default=dict)
timestamp = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["timestamp"]
def to_prose(self):
"""Return a human-readable action description (actor rendered separately in template)."""
d = self.data
if self.verb == self.SLOT_FILLED:
_token_names = {
"coin": "Coin-on-a-String", "Free": "Free Token",
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
}
code = d.get("token_type", "token")
token = d.get("token_display") or _token_names.get(code, code)
days = d.get("renewal_days", 7)
slot = d.get("slot_number", "?")
return f"deposits a {token} for slot {slot} (expires in {days} days)."
if self.verb == self.SLOT_RESERVED:
return "reserves a seat"
if self.verb in (self.SLOT_RETURNED, self.SLOT_RELEASED):
# Symmetric counterpart to SLOT_FILLED's "deposits a {token} for
# slot {#} …" — same shape so the redact-pair (strikethrough on
# the prior deposit, new withdraw entry below it) reads as a
# mirror image in the room scroll. User-spec 2026-05-26 sprint
# A.8. SLOT_RETURNED + SLOT_RELEASED both render w. this prose;
# the verb distinction stays in the data layer (different paths
# trigger them — full token return vs. per-slot CARTE release).
_token_names = {
"coin": "Coin-on-a-String", "Free": "Free Token",
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
}
code = d.get("token_type", "token")
token = d.get("token_display") or _token_names.get(code, code)
slot = d.get("slot_number", "?")
_, _, poss = _actor_pronouns(self.actor)
return f"withdraws {poss} {token} from slot {slot}."
if self.verb == self.ROOM_CREATED:
# First scroll log on a fresh room — system-authored greeting
# (actor=None upstream). Format intentionally drops the actor
# prefix the rest of the verbs use; the room's title is what
# the welcome line celebrates.
return f"Welcome to {self.room.name}!"
if self.verb == self.INVITE_SENT:
return "sends an invitation"
if self.verb == self.ROLE_SELECT_STARTED:
return "Role selection begins"
if self.verb == self.ROLE_SELECTED:
_role_names = {
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
}
_chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"]
_ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"]
code = d.get("role", "?")
role = d.get("role_display") or _role_names.get(code, code)
try:
ordinal = _ordinals[_chair_order.index(code)]
except ValueError:
ordinal = "?"
subj, _, _ = _actor_pronouns(self.actor)
return f"assumes {ordinal} Chair; {subj} will start the game as the {role}."
if self.verb == self.ROLES_REVEALED:
return "All roles assigned"
if self.verb == self.SIG_READY:
card_name = d.get("card_name", "a card")
corner_rank = d.get("corner_rank", "")
suit_icon = d.get("suit_icon", "")
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 = ""
# Trump cards ("The Schizo", "The Nomad", "The Wanderer") drop
# their "The " in this rendering: the prose template already
# supplies "the", and a levity/gravity qualifier (e.g. "Engraven"
# in "Engraven The Nomad") needs to butt up against the proper name.
card_name = card_name.replace("The ", "", 1)
_, _, poss = _actor_pronouns(self.actor)
return f"embodies as {poss} Significator the {card_name}{abbrev}."
if self.verb == self.SIG_UNREADY:
_, _, poss = _actor_pronouns(self.actor)
return f"disembodies {poss} Significator."
if self.verb == self.SKY_SAVED:
_, obj, poss = _actor_pronouns(self.actor)
caps = list(d.get("top_capacitors") or [])
if not caps:
return f"beholds the skyscape of {poss} birth."
if len(caps) == 1:
return (
f"beholds the skyscape of {poss} birth, "
f"which yields {obj} a unique {caps[0]} capacity."
)
# Tied highest: "equal X and Y capacities" (2-way) or
# "equal X, Y, and Z capacities" (3+, Oxford comma).
if len(caps) == 2:
joined = f"{caps[0]} and {caps[1]}"
else:
joined = ", ".join(caps[:-1]) + f", and {caps[-1]}"
return (
f"beholds the skyscape of {poss} birth, "
f"which yields {obj} equal {joined} capacities."
)
if self.verb == self.SEA_DRAWN:
# Personalized per-Role affinity (user-spec 2026-06-09): the card drawn
# into the gamer's Role-correlated Celtic position, woven into a
# role-specific clause. The actor's @handle is prepended by the
# template; pronouns resolve via the actor (subj/poss). "The " is
# stripped so a levity/gravity qualifier butts the proper name; the
# corner-rank + suit-icon abbrev rides the card name (mirrors SIG_READY).
card = d.get("card_name", "a card").replace("The ", "", 1)
corner_rank = d.get("corner_rank", "")
suit_icon = d.get("suit_icon", "")
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_ref = f"the {card}{abbrev}"
subj, _, poss = _actor_pronouns(self.actor)
# they + yo are PLURAL → the plural verb "leave" (not "leaves"); the
# singular he/she/it keep the 3rd-person "leaves" (user-spec).
leaves = "leave" if subj in ("they", "yo") else "leaves"
clause = {
"PC": f"{card_ref} crowns all {poss} loftiest illusions",
"NC": f"{card_ref} traces all the narratives {subj} {leaves} behind",
"EC": f"{card_ref} always looms before {poss} calling",
"SC": f"{card_ref} covers all {poss} righteous conduct",
"AC": f"{card_ref} always crosses {poss} sinister connections",
"BC": f"{card_ref} lays all {poss} foundational work",
}.get(d.get("role", ""), f"{card_ref} marks {poss} affinity")
return f"draws {poss} Sea of cards, where {clause}."
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
def struck(self):
"""True when this SIG_READY event was subsequently retracted (WAIT NVM)."""
return self.data.get("retracted", False)
def to_activity(self, base_url):
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
if not self.actor or not self.actor.username:
return None
actor_url = f"{base_url}/ap/users/{self.actor.username}/"
room_url = f"{base_url}/gameboard/room/{self.room_id}/"
if self.verb == self.SLOT_FILLED:
return {
"type": "earthman:JoinGate",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
if self.verb == self.ROLE_SELECTED:
return {
"type": "earthman:SelectRole",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
if self.verb == self.ROOM_CREATED:
return {
"type": "Create",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
return None
def __str__(self):
actor = self.actor.email if self.actor else "system"
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}"
class ScrollPosition(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="scroll_positions",
)
room = models.ForeignKey(
"epic.Room", on_delete=models.CASCADE,
related_name="scroll_positions",
)
position = models.PositiveIntegerField(default=0)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = [("user", "room")]
def __str__(self):
return f"{self.user.email} @ {self.room.name}: {self.position}px"
def _broadcast_scroll_update(room_id):
"""Nudge every open room socket to re-fetch the scroll-of-events feed so the
SCROLL applet updates live (not just on page refresh). Guarded — a missing
or unreachable channel layer must NEVER break event recording, so any error
is swallowed (the feed simply falls back to refresh-to-update)."""
try:
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
layer = get_channel_layer()
if layer is None:
return
async_to_sync(layer.group_send)(
f"room_{room_id}", {"type": "scroll_update"})
except Exception:
pass
def record(room, verb, actor=None, **data):
"""Record a game event in the drama log.
Broadcasts a `scroll_update` to the room group AFTER the surrounding
transaction commits (`on_commit`) so the live re-fetch sees the new row,
and so a rolled-back TestCase never fires it (zero overhead/risk for the
plain IT suite). RoomConsumer relays it → room-scroll.js swaps the feed."""
from django.db import transaction
event = GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
transaction.on_commit(lambda: _broadcast_scroll_update(room.id))
return event
_NOTE_DISPLAY = {
"stargazer": {"greeting": "Welcome,", "title": "Stargazer"},
"schizo": {"greeting": "Welcome,", "title": "Schizo"},
"nomad": {"greeting": "Welcome,", "title": "Nomad"},
"super-schizo": {"greeting": "21<span class='ord'>st</span> Century", "title": "Schizoid Man"},
"super-nomad": {"greeting": "Howdy,", "title": "Stranger"},
"baltimorean": {"greeting": "Ayo,", "title": "Ard!", "card_title": "Baltimorean"},
}
# Note slugs whose grant prose uses the long admin format ("The administration
# recognizes…") rather than the standard "Look!—new Note unlocked…" format.
# Any slug not in this set gets the standard format.
_ADMIN_NOTE_SLUGS = frozenset({"super-schizo", "super-nomad"})
# Hardcoded title for the per-user "Note unlocks" Post — supplants any
# first-line-glean for posts of kind=NOTE_UNLOCK.
NOTE_UNLOCK_POST_TITLE = "Notes & recognitions"
class Note(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="notes",
)
slug = models.SlugField(max_length=60)
earned_at = models.DateTimeField()
palette = models.CharField(max_length=60, null=True, blank=True)
class Meta:
unique_together = [("user", "slug")]
ordering = ["earned_at"]
def __str__(self):
return f"{self.user.email}{self.slug}"
@property
def display_title(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("title", self.slug.replace("-", " ").title())
@property
def display_greeting(self):
return _NOTE_DISPLAY.get(self.slug, {}).get("greeting", "Welcome,")
@property
def card_title(self):
"""The string shown in the my-notes card's "Title:" row. Defaults to
`display_title` (the don-able title — most slugs render the title you
DON here, e.g. "Schizoid Man" for super-schizo). Baltimorean overrides
so the card reads "Baltimorean" instead of the navbar-only "Ard!"
flair."""
return _NOTE_DISPLAY.get(self.slug, {}).get("card_title", self.display_title)
@property
def display_name(self):
"""The Note's *name* (e.g., "Stargazer", "Super-Schizo") — the heading
rendered on the my-notes card. Distinct from `display_title` which is
the *recognition title* the user dons (e.g., "Schizoid Man" for
super-schizo). For all current slugs `slug.title()` recovers the right
casing (.title() capitalizes after non-letter chars, so "super-schizo"
"Super-Schizo"); special-case in `_NOTE_DISPLAY[slug]["name"]` if a
future slug needs a different rendering."""
return _NOTE_DISPLAY.get(self.slug, {}).get("name", self.slug.title())
@classmethod
def grant_if_new(cls, user, slug):
"""Grants the Note if it doesn't already exist on the user; on a fresh
grant ALSO appends a Line to the user's per-category "Notes &
recognitions" Post (creating the Post on first-ever unlock) and spawns
a Brief that FKs the appended Line. Returns ``(note, created, brief)``
— brief is None on idempotent re-grants. Banner-side affordances (FYI
navigation, my-notes square) ride on Brief.kind=NOTE_UNLOCK.
Line text dispatches by slug: admin-grant slugs (super-schizo,
super-nomad) use the long "The administration recognizes…" format;
every other slug uses the standard "Look!—new Note unlocked. {Note
name} recognizes {username} the {title}." format. Both wrap the Note
name in a `note-ref` anchor pointing at /billboard/my-notes/.
Author is hardcoded to the seeded `adman` User; the per-line username
column then attributes the Line correctly."""
from django.utils import timezone
from apps.billboard.models import Brief, Line, Post
from apps.lyric.models import get_or_create_adman
note, created = cls.objects.get_or_create(
user=user, slug=slug,
defaults={"earned_at": timezone.now()},
)
if not created:
return note, created, None
post, _ = Post.objects.get_or_create(
owner=user, kind=Post.KIND_NOTE_UNLOCK,
defaults={"title": NOTE_UNLOCK_POST_TITLE},
)
# Existing Note-unlock Posts (pre-0004 migration) might lack a title
# if they predate this code path's get_or_create defaults. Heal once.
if post.title != NOTE_UNLOCK_POST_TITLE:
post.title = NOTE_UNLOCK_POST_TITLE
post.save(update_fields=["title"])
# Bare-email fallback when user.username is None (no `@` prefix —
# the address already carries one). When username is set, use the
# `@handle` form. Both wrapped in .post-attribution so the CSS
# palette key (--quaUser) lights up the username + title combo.
handle = f"@{user.username}" if user.username else user.email
note_anchor = (
f'<a class="note-ref" href="/billboard/my-notes/">'
f'{note.display_name}</a>'
)
attr_handle = f'<span class="post-attribution">{handle}</span>'
attr_title = f'<span class="post-attribution">{note.display_title}</span>'
if slug in _ADMIN_NOTE_SLUGS:
line_text = (
f"The administration recognizes {attr_handle} for {note_anchor}, "
f"which bestows the honorary title of {attr_title}. "
"This does not entail any additional corporate benefits."
)
else:
# Inline attribution reads "{handle} the {Note name}", not the
# don-able title. For most slugs the two coincide ("the Stargazer")
# but Baltimorean's title is the navbar flair "Ard!" — "the Ard!"
# reads oddly inline; "the Baltimorean" matches the Note name.
attr_combo = (
f'<span class="post-attribution">{handle} '
f'the {note.display_name}</span>'
)
line_text = (
f"Look!—new Note unlocked. {note_anchor} "
f"recognizes {attr_combo}."
)
# Lazy get-or-create: TransactionTestCase flushes the migration-seeded
# adman row, so tests that create superusers (which auto-grants
# super-schizo + super-nomad via the User post_save signal) need a
# safety net. Production migrations seed it once.
adman = get_or_create_adman()
line = Line.objects.create(
post=post, text=line_text, author=adman,
admin_solicited=True,
)
brief = Brief.objects.create(
owner=user,
post=post,
line=line,
kind=Brief.KIND_NOTE_UNLOCK,
title=note.display_name,
)
return note, created, brief