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

@@ -18,7 +18,8 @@
"seance": "sesión",
"enter": "entrar",
"logout": "partir",
"log": "registro"
"log": "registro",
"hunters": "lista"
},
"log": {
"title": "REGISTRO ESPECTRAL",
@@ -620,5 +621,115 @@
"idle": "el velo está en silencio — no se detecta presencia",
"waiting": "escuchando indicios…",
"meterLabel": "medidor de maldad"
},
"profile": {
"back": "← la mesa",
"title": "TU SIGILO",
"subtitle": "cómo el velo te recordará",
"loading": "leyendo tu sigilo…",
"loadError": "tu sigilo no se aclara — el cristal está nublado",
"save": "séllalo",
"saving": "sellando…",
"saved": "sellado — el velo lo ha tomado",
"saveError": "el velo rechazó el cambio",
"viewPublic": "mira lo que otros ven",
"rankLine": "{{title}} · rango {{level}}",
"titles": {
"curious": "curioso",
"sensitive": "sensitivo",
"channeler": "canalizador",
"medium": "médium",
"adept": "adepto",
"oracle": "oráculo"
},
"wanderer": {
"body": "vagas aquí sin nombre. a los errantes se les permite mirar, pero el velo los olvida al amanecer — reclama un nombre y tu sigilo perdurará.",
"claim": "reclamar un nombre"
},
"avatar": {
"heading": "tu forma",
"note": "no se toma ninguna imagen. tu sigilo se traza con una forma y un color, igual que se trazan los muertos.",
"formLabel": "elige una forma",
"hueLabel": "color · {{hue}}°"
},
"identity": {
"heading": "tu nombre",
"nameLabel": "nombre que otros leerán",
"nameHint": "déjalo vacío y el velo usará tu nombre verdadero",
"nameTooLong": "no más de {{max}} caracteres",
"bioLabel": "unas palabras sobre ti",
"bioCount": "quedan {{remaining}} de {{max}}",
"bioTooLong": "no más de {{max}} caracteres"
},
"gender": {
"label": "cómo te nombran",
"male": "él",
"female": "ella",
"unspecified": "sin decir"
},
"veil": {
"heading": "el velo sobre ti",
"public": "visible a otros buscadores",
"private": "velado",
"publicNote": "tu sigilo, tu rango y tus contactos recientes podrán leerse por quien sepa tu nombre.",
"privateNote": "nadie te hallará en la lista. tus sesiones siguen igual."
},
"email": {
"label": "un camino de vuelta (opcional)",
"note": "sólo para recuperar tu cuenta. nunca se muestra en tu página, nunca se da a otro buscador, nunca se usa para nada más.",
"invalid": "esa dirección no llevará ningún mensaje"
}
},
"hunters": {
"back": "← la mesa",
"backToRoster": "← la lista",
"title": "LA LISTA",
"subtitle": "todo buscador que dejó su nombre en el velo",
"loading": "pasando lista…",
"empty": "nadie ha dejado su nombre aún",
"card": {
"encounters_one": "{{count}} espíritu alcanzado",
"encounters_other": "{{count}} espíritus alcanzados"
},
"profile": {
"loading": "trazando su sigilo…",
"veiledTitle": "ningún rastro de ellos",
"veiledBody": "el velo no guarda registro de «{{username}}». o el nombre nunca fue reclamado, o han corrido el velo sobre sí mismos.",
"progressLabel": "avance hacia el siguiente rango",
"progressNext_one": "{{count}} espíritu más para ascender",
"progressNext_other": "{{count}} espíritus más para ascender",
"progressMax": "hasta donde llega el velo",
"encounters": "espíritus alcanzados",
"joined": "primer cruce",
"recentTitle": "alcanzados hace poco",
"recentEmpty": "aún no han alcanzado a nadie",
"sendMessage": "enviar palabra"
}
},
"messages": {
"title": "susurros entre cazadores",
"findHunters": "buscar cazadores",
"emptyInbox": "nadie te ha susurrado aún. busca a un cazador cuyo códice admires, y di algo.",
"emptyThread": "nada ha pasado entre ustedes todavía.",
"needAccount": "los susurros necesitan un nombre. la voz de un errante no llega lejos.",
"claimName": "reclama un nombre",
"backToInbox": "volver a los susurros",
"noSuchHunter": "ningún cazador camina aquí.",
"placeholder": "habla, y llegará…",
"send": "enviar",
"sending": "enviando…",
"sendFailed": "el susurro no llegó. inténtalo de nuevo.",
"guestBlocked": "un errante no puede ser oído. reclama un nombre y tus susurros llegarán.",
"youPrefix": "tú: ",
"unread": "susurros sin leer",
"time": {
"now": "ahora mismo",
"minutes_one": "hace {{count}} minuto",
"minutes_other": "hace {{count}} minutos",
"hours_one": "hace {{count}} hora",
"hours_other": "hace {{count}} horas",
"days_one": "hace {{count}} día",
"days_other": "hace {{count}} días"
}
}
}