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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user