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

@@ -1,9 +1,13 @@
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy import select
from app.models.contact_session import ContactSession
from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.user import User
def _make_entity(name="Vesper Wren", rarity="rare", signature="abc123def4567890"):
@@ -81,3 +85,112 @@ async def test_stats_counts_veil_activity(client, db_session):
async def test_codex_is_public_without_auth(client):
assert (await client.get("/api/codex")).status_code == 200
assert (await client.get("/api/stats")).status_code == 200
# --- the encounter record: who summoned a spirit, and who else has met it ---
# (hunter-profiles wave). The data already existed — every contact is an
# EntitySighting row, discovery is Entity.discovered_by.
async def _hunter(db_session, username: str, **profile):
"""A hunter. `profile` fields (display_name, profile_public, …) are set
only if the parallel profile migration has landed, so these tests pass
either way."""
user = User(username=username, password_hash="x")
for key, value in profile.items():
if hasattr(User, key):
setattr(user, key, value)
db_session.add(user)
await db_session.flush()
return user
async def _contact(db_session, entity, user, seen_at):
session = ContactSession(user_id=user.id, entity_id=entity.id, mode="wire")
db_session.add(session)
await db_session.flush()
db_session.add(
EntitySighting(
entity_id=entity.id,
session_id=session.id,
user_id=user.id,
seen_at=seen_at,
)
)
@pytest.mark.asyncio
async def test_detail_surfaces_the_discoverer(client, db_session):
entity = _make_entity()
db_session.add(entity)
summoner = await _hunter(db_session, "mora", display_name="Mora Vane")
await db_session.flush()
entity.discovered_by = summoner.id
await _contact(
db_session, entity, summoner, datetime(2026, 7, 1, tzinfo=timezone.utc)
)
await db_session.commit()
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["discovered_by"] == "mora"
assert body["discoverer"]["username"] == "mora"
assert body["discoverer"]["times_contacted"] == 1
assert body["discoverer"]["last_seen"] is not None
# Public by default, so the dossier may link to their hunter page.
assert body["discoverer_public"] is True
assert body["discoverer"]["display_name"] in {"mora", "Mora Vane"}
@pytest.mark.asyncio
async def test_encounter_roster_aggregates_per_hunter(client, db_session):
entity = _make_entity()
db_session.add(entity)
twice = await _hunter(db_session, "reva")
once = await _hunter(db_session, "isolde")
await _contact(db_session, entity, twice, datetime(2026, 7, 1, tzinfo=timezone.utc))
await _contact(db_session, entity, twice, datetime(2026, 7, 2, tzinfo=timezone.utc))
await _contact(db_session, entity, once, datetime(2026, 7, 9, tzinfo=timezone.utc))
await db_session.commit()
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["sightings"] == 3
# Distinct hunters, not sighting rows.
assert body["total_encounters"] == 2
roster = body["encounters"]
assert [h["username"] for h in roster] == ["isolde", "reva"] # newest first
by_name = {h["username"]: h for h in roster}
assert by_name["reva"]["times_contacted"] == 2
assert by_name["isolde"]["times_contacted"] == 1
assert by_name["reva"]["last_seen"].startswith("2026-07-02")
@pytest.mark.asyncio
async def test_private_hunters_are_counted_but_not_linkable(client, db_session):
if not hasattr(User, "profile_public"):
pytest.skip("profile_public lands with the profile workstream")
entity = _make_entity()
db_session.add(entity)
hidden = await _hunter(db_session, "nocturne", profile_public=False)
seen = await _hunter(db_session, "calla", profile_public=True)
await _contact(db_session, entity, hidden, datetime(2026, 7, 3, tzinfo=timezone.utc))
await _contact(db_session, entity, seen, datetime(2026, 7, 4, tzinfo=timezone.utc))
await db_session.commit()
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["total_encounters"] == 2
flags = {h["username"]: h["public"] for h in body["encounters"]}
assert flags == {"nocturne": False, "calla": True}
@pytest.mark.asyncio
async def test_unmet_spirit_has_an_empty_roster(client, db_session):
entity = _make_entity()
db_session.add(entity)
await db_session.commit()
await db_session.refresh(entity)
body = (await client.get(f"/api/codex/{entity.id}")).json()
assert body["encounters"] == []
assert body["total_encounters"] == 0
assert body["discoverer"] is None
assert body["discoverer_public"] is False