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:
@@ -15,6 +15,8 @@ from app.routes.codex import router as codex_router
|
||||
from app.routes.conditions import router as conditions_router
|
||||
from app.routes.device import router as device_router
|
||||
from app.routes.inventory import router as inventory_router
|
||||
from app.routes.messages import router as messages_router
|
||||
from app.routes.profile import router as profile_router
|
||||
from app.routes.seances import router as seances_router
|
||||
from app.routes.seo import router as seo_router
|
||||
from app.routes.shop import router as shop_router
|
||||
@@ -71,6 +73,23 @@ async def lifespan(app: FastAPI):
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
|
||||
))
|
||||
# Hunter profile columns. All nullable (or defaulted) so existing
|
||||
# rows, guests included, stay valid without a backfill.
|
||||
for column, ddl in (
|
||||
("display_name", "VARCHAR(48)"),
|
||||
("bio", "VARCHAR(280)"),
|
||||
("gender", "VARCHAR(16)"),
|
||||
("avatar_form", "VARCHAR(16)"),
|
||||
("avatar_hue", "INTEGER"),
|
||||
):
|
||||
await conn.execute(
|
||||
text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {column} {ddl}")
|
||||
)
|
||||
await conn.execute(text(
|
||||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
|
||||
"profile_public BOOLEAN NOT NULL DEFAULT true"
|
||||
))
|
||||
|
||||
# Defense-in-depth: purchase_unlock() already enforces one row per
|
||||
# (user, unlock_key) via a row-locked check-then-insert, so this
|
||||
# constraint should never actually find a conflict on a live DB.
|
||||
@@ -100,6 +119,8 @@ app.include_router(codex_router)
|
||||
app.include_router(conditions_router)
|
||||
app.include_router(device_router)
|
||||
app.include_router(inventory_router)
|
||||
app.include_router(messages_router)
|
||||
app.include_router(profile_router)
|
||||
app.include_router(seances_router)
|
||||
# Registered before the SPA catch-all below, or /robots.txt and
|
||||
# /sitemap.xml would be served index.html instead.
|
||||
|
||||
Reference in New Issue
Block a user