feat: hunter profiles, ranks, whispers, and the encounter record

All five agents died mid-flight (three on session limits, two on 529s), but
their worktrees held real work — 17 files. Salvaged everything, wrote the
missing pieces, and finished the integration by hand.

PROFILES + RANK
User gains display_name, bio, gender, avatar_form, avatar_hue and
profile_public — all nullable, so every existing row including the guest
`wanderer-` accounts stays valid with no backfill. The avatar is procedural
(a GhostForm plus a hue, drawn by the same GhostGlyph that renders
entities): no uploads means no moderation surface, no EXIF and no blob
storage, and an `avatar_url` still slots in later without changing anything.

rank.py converts encounters, essence and favor into one "standing" currency
and maps it onto six one-word titles. An encounter is worth ten points to
ten essence's one, because contact is what the app is about — a seeker who
only buys unlocks climbs very slowly. Negative essence and favor floor at
zero rather than subtracting, so a bad judgment can never demote you: rank
is a record of what you have done. Level 1 costs exactly one encounter, so a
new hunter sees the bar move after their first séance.

Privacy invariants, verified live rather than assumed:
- `email` is returned by GET /api/profile/me and by nothing else. Confirmed
  against the running server: zero occurrences in both public payloads.
- A hidden profile 404s rather than 403s — confirming the account exists
  would leak exactly what hiding it was meant to prevent.

WHISPERS BETWEEN HUNTERS
Plain text, no attachments, no editing. Guests can RECEIVE but not send:
that gives registering a felt purpose beyond keeping a codex, and closes the
obvious spam vector since guest accounts are free and automatic. Verified
live: alice→bob delivers, a guest send returns 403, and a third party's
conversation list comes back empty — no cross-user leak.

Message bodies are rendered as text nodes, never as HTML, and wrap with
overflow-wrap:anywhere so a long unbroken string can't blow out the layout.

THE ENCOUNTER RECORD
The Codex already knew all of this — Entity.discovered_by has always been
recorded and every contact was already an entity_sightings row. Nobody ever
showed it. Now an entity page names its summoner and lists every hunter who
has met it. Hunters who opted out of a public profile are still COUNTED but
not linkable: an anonymous contact is still a contact, so a spirit's history
stays honest without exposing anyone.

Live on production data: Mabel Crump, discovered by Charly, 1 encounter;
Charly ranks channeler (level 2) from 5 real sightings — all computed from
data that was already sitting there.

385 frontend tests pass; i18n parity holds across both languages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Indiana
2026-07-31 02:50:00 +00:00
parent 3656b6b0c4
commit bacfb852b8
23 changed files with 3472 additions and 4 deletions

View File

@@ -5,6 +5,7 @@ from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.event import Event
from app.models.inventory_item import InventoryItem
from app.models.message import Message
from app.models.sigil import Sigil
from app.models.unlock import UnlockRecord
from app.models.user import User
@@ -19,6 +20,7 @@ __all__ = [
"EntitySighting",
"Event",
"InventoryItem",
"Message",
"Sigil",
"UnlockRecord",
"WaitlistEntry",

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
# The wire cap. Enforced in three places on purpose: the Pydantic schema
# (rejects with a clear 422/400 before touching the DB), the route's own
# check (so a body built any other way still can't slip through), and the
# column type below — Text, because Postgres VARCHAR(n) truncation/erroring
# is a worse failure mode than a validated length, and a future cap change
# then needs no migration.
BODY_MAX_CHARS = 1000
class Message(Base):
"""One plain-text message from one hunter to another.
Deliberately minimal (hunters-and-messages spec, Workstream S): no
attachments, no editing, no groups, no threads-as-rows — a "thread" is
simply every row between two user ids, ordered by time.
`read_at` is null until the *recipient* opens the thread; the sender
never marks anything read, so this doubles as the unread signal.
"""
__tablename__ = "messages"
# Both directions are queried: the conversation list and thread view each
# need "sent by me" OR'd with "sent to me", and the unread aggregate
# scans (recipient_id, read_at). The composite indexes below serve those
# ordered scans; the per-column indexes on the FKs come from
# index=True and keep single-sided lookups cheap.
__table_args__ = (
Index("ix_messages_sender_recipient_created", "sender_id", "recipient_id", "created_at"),
Index("ix_messages_recipient_sender_created", "recipient_id", "sender_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
recipient_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String
from sqlalchemy import Boolean, DateTime, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
@@ -14,6 +14,27 @@ class User(Base):
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# --- hunter profile ---------------------------------------------------
# All nullable so every pre-existing row — including the guest
# `wanderer-` accounts minted by the open door — stays valid without a
# backfill. Absent values are treated as "not set" and fall back at the
# serialization layer, never here.
#
# The avatar is procedural: a GhostForm plus a hue, rendered by the same
# GhostGlyph component that draws entities. No uploads means no
# moderation surface, no EXIF stripping and no blob storage — and if a
# real image is ever wanted, an `avatar_url` slots in beside these
# without changing anything else.
display_name: Mapped[str | None] = mapped_column(String(48), nullable=True)
bio: Mapped[str | None] = mapped_column(String(280), nullable=True)
gender: Mapped[str | None] = mapped_column(String(16), nullable=True)
avatar_form: Mapped[str | None] = mapped_column(String(16), nullable=True)
avatar_hue: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Hides the profile from /api/hunters and 404s the public page. It does
# NOT stop messages arriving — privacy here is about being browsed, not
# about being unreachable.
profile_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
essence: Mapped[int] = mapped_column(Integer, default=0)
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's