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

@@ -16,6 +16,97 @@ from app.models.user import User
router = APIRouter(prefix="/api", tags=["codex"])
# How many distinct hunters the dossier names. Everyone else is still
# counted in `total_encounters` — the roster is a window, not the truth.
ROSTER_LIMIT = 20
def _user_column(name: str):
"""The profile columns land in `users` via a parallel workstream (the
hunter-profiles spec §"Data model"). Until that migration exists this
module must not reference them in SQL at all, or every Codex request
500s on an unknown column. Resolve them by name and fall back to a
literal default, so the same code path works before and after.
"""
return getattr(User, name, None)
def _hunter_entry(
username: str,
display_name: str | None,
avatar_form: str | None,
avatar_hue: int | None,
profile_public: bool | None,
times: int,
last_seen,
) -> dict:
# A hidden profile is still a hunter who was there: counted, named
# nowhere. `public` is what the UI keys the link off.
public = True if profile_public is None else bool(profile_public)
return {
"username": username,
"display_name": display_name or username,
"avatar_form": avatar_form,
"avatar_hue": avatar_hue,
"public": public,
"times_contacted": times,
"last_seen": last_seen.isoformat() if last_seen else None,
}
async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]:
"""The roster of hunters who have contacted this spirit, newest-first.
ONE aggregate query — group the sightings by hunter and carry the count
and latest contact out of the same scan. Never a lookup per hunter.
"""
display_name = _user_column("display_name")
avatar_form = _user_column("avatar_form")
avatar_hue = _user_column("avatar_hue")
profile_public = _user_column("profile_public")
last_seen = func.max(EntitySighting.seen_at).label("last_seen")
times = func.count(EntitySighting.id).label("times")
columns = [User.username, times, last_seen]
optional = [display_name, avatar_form, avatar_hue, profile_public]
columns.extend(col for col in optional if col is not None)
rows = (
await db.execute(
select(*columns)
.join(User, User.id == EntitySighting.user_id)
.where(EntitySighting.entity_id == entity_id)
.group_by(User.id)
.order_by(desc(last_seen))
.limit(ROSTER_LIMIT)
)
).all()
roster = [
_hunter_entry(
row.username,
getattr(row, "display_name", None) if display_name is not None else None,
getattr(row, "avatar_form", None) if avatar_form is not None else None,
getattr(row, "avatar_hue", None) if avatar_hue is not None else None,
getattr(row, "profile_public", None) if profile_public is not None else None,
row.times,
row.last_seen,
)
for row in rows
]
# Distinct hunters, not sighting rows (`sightings` already reports those)
# — so the UI can honestly say "and N more" past the roster window.
total = (
await db.scalar(
select(func.count(func.distinct(EntitySighting.user_id))).where(
EntitySighting.entity_id == entity_id
)
)
or 0
)
return roster, total
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
return {
@@ -72,17 +163,45 @@ async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
discoverer = None
discoverer_entry = None
if entity.discovered_by:
user = await db.get(User, entity.discovered_by)
discoverer = user.username if user else None
if user is not None:
discoverer = user.username
discoverer_entry = _hunter_entry(
user.username,
getattr(user, "display_name", None),
getattr(user, "avatar_form", None),
getattr(user, "avatar_hue", None),
getattr(user, "profile_public", None),
0,
None,
)
sightings = await db.scalar(
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
)
roster, total_encounters = await _encounters(db, entity.id)
card = _entity_card(entity, discoverer)
card["persona"] = entity.persona
card["voice"] = entity.voice_profile
card["sightings"] = sightings or 0
# The summoner, with enough to render a glyph and decide on a link.
# `times_contacted`/`last_seen` come from their roster row when they
# have one — discovery alone doesn't imply a surviving sighting row.
if discoverer_entry is not None:
for hunter in roster:
if hunter["username"] == discoverer_entry["username"]:
discoverer_entry["times_contacted"] = hunter["times_contacted"]
discoverer_entry["last_seen"] = hunter["last_seen"]
break
card["discoverer"] = discoverer_entry
card["discoverer_public"] = (
discoverer_entry["public"] if discoverer_entry else False
)
card["encounters"] = roster
card["total_encounters"] = total_encounters
return card