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>
437 lines
20 KiB
Python
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
|