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' ' 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' ' 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": "21st 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'' f'{note.display_name}' ) attr_handle = f'{handle}' attr_title = f'{note.display_title}' 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'{handle} ' f'the {note.display_name}' ) 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