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": "séance",
"enter": "enter",
"logout": "depart",
"log": "log"
"log": "log",
"hunters": "hunters"
},
"log": {
"title": "GHOST LOG",
@@ -620,5 +621,115 @@
"idle": "the veil is quiet — no presence detected",
"waiting": "listening for tells…",
"meterLabel": "evil meter"
},
"profile": {
"back": "← the table",
"title": "YOUR SIGIL",
"subtitle": "how the veil will remember you",
"loading": "reading your sigil…",
"loadError": "your sigil will not resolve — the glass is clouded",
"save": "seal it",
"saving": "sealing…",
"saved": "sealed — the veil has taken it",
"saveError": "the veil refused the change",
"viewPublic": "see what others see",
"rankLine": "{{title}} · rank {{level}}",
"titles": {
"curious": "curious",
"sensitive": "sensitive",
"channeler": "channeler",
"medium": "medium",
"adept": "adept",
"oracle": "oracle"
},
"wanderer": {
"body": "you drift here without a name. wanderers may look, but the veil forgets them at dawn — claim a name and your sigil will hold.",
"claim": "claim a name"
},
"avatar": {
"heading": "your shape",
"note": "no likeness is taken. your sigil is drawn from a form and a colour, the same way the dead are drawn.",
"formLabel": "choose a form",
"hueLabel": "colour · {{hue}}°"
},
"identity": {
"heading": "your name",
"nameLabel": "name others will read",
"nameHint": "leave it blank and the veil will use your true name",
"nameTooLong": "no more than {{max}} characters",
"bioLabel": "a few words on yourself",
"bioCount": "{{remaining}} of {{max}} left",
"bioTooLong": "no more than {{max}} characters"
},
"gender": {
"label": "how you are addressed",
"male": "he",
"female": "she",
"unspecified": "unspoken"
},
"veil": {
"heading": "the veil over you",
"public": "visible to other seekers",
"private": "veiled",
"publicNote": "your sigil, rank and recent contacts can be read by anyone who knows your name.",
"privateNote": "no one will find you on the roll. your séances continue unchanged."
},
"email": {
"label": "a way back (optional)",
"note": "for recovering your account only. never shown on your page, never given to another seeker, never used to reach you otherwise.",
"invalid": "that address will not carry a message"
}
},
"hunters": {
"back": "← the table",
"backToRoster": "← the roll",
"title": "THE ROLL",
"subtitle": "every seeker who left their name on the veil",
"loading": "calling the roll…",
"empty": "no one has left a name yet",
"card": {
"encounters_one": "{{count}} spirit reached",
"encounters_other": "{{count}} spirits reached"
},
"profile": {
"loading": "tracing their sigil…",
"veiledTitle": "no trace of them",
"veiledBody": "the veil holds no record of “{{username}}”. either the name was never claimed, or they have drawn the veil over themselves.",
"progressLabel": "progress toward the next rank",
"progressNext_one": "{{count}} more spirit to rise",
"progressNext_other": "{{count}} more spirits to rise",
"progressMax": "as far as the veil goes",
"encounters": "spirits reached",
"joined": "first crossed",
"recentTitle": "recently reached",
"recentEmpty": "they have reached no one yet",
"sendMessage": "send word"
}
},
"messages": {
"title": "whispers between hunters",
"findHunters": "find hunters",
"emptyInbox": "no one has whispered to you yet. find a hunter whose codex you admire, and say something.",
"emptyThread": "nothing has passed between you yet.",
"needAccount": "whispers need a name. a wanderer's voice does not carry.",
"claimName": "claim a name",
"backToInbox": "back to the whispers",
"noSuchHunter": "no such hunter walks here.",
"placeholder": "speak, and it will carry…",
"send": "send",
"sending": "sending…",
"sendFailed": "the whisper did not carry. try again.",
"guestBlocked": "a wanderer cannot be heard. claim a name and your whispers will carry.",
"youPrefix": "you: ",
"unread": "unread whispers",
"time": {
"now": "just now",
"minutes_one": "{{count}} minute past",
"minutes_other": "{{count}} minutes past",
"hours_one": "{{count}} hour past",
"hours_other": "{{count}} hours past",
"days_one": "{{count}} day past",
"days_other": "{{count}} days past"
}
}
}

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"
}
}
}