# Hunter Profiles — identity, rank, and a low-key social layer Registering already keeps your codex and unlocks device pairing. This adds the reason to *want* an account: an identity other seekers can see, a rank that grows with real activity, and a public profile. Binding rules for every workstream (same as the usability wave): - **Additive only.** Nothing removed, no restructuring outside your files. - **Guests stay first-class.** A wanderer must keep working exactly as it does today; profile fields are simply absent/defaulted for them. Never gate the séance behind a profile. - **i18n parity is a hard gate.** Every user-facing string via `t()`, keys in BOTH `src/i18n/en.json` and `es.json`. `npm run pretest` must pass. - **Tone:** in-fiction throughout (seeker / hunter / the veil). - Backend tests run: `cd backend && set -a && source ../.env && set +a && source venv/bin/activate && python -m pytest tests/ -q -p no:cacheprovider` - Frontend: `npx tsc --noEmit -p .`, `npm run pretest`, `npx vitest run`. ## Data model (Workstream P owns this; others consume it) Extend `backend/app/models/user.py` — new nullable columns only, so every existing row (including guests) stays valid: - `email: str | None` (unique when set, index) — optional, used for account recovery only. **Never returned by any public endpoint.** - `display_name: str | None` — shown publicly; falls back to `username`. - `bio: str | None` (<= 280 chars) — optional, public. - `gender: str | None` — one of `male` / `female` / `unspecified`; defaults to `unspecified` when absent. Public. - `avatar_form: str | None` — one of the existing GhostForm values (`wisp`/`banshee`/`fairy`/`shade`). - `avatar_hue: int | None` — 0-359. - `profile_public: bool` default `True` — a seeker can hide their profile. Migration: idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` lines in `main.py`'s lifespan, matching the existing style there exactly. **Avatar is procedural, not uploaded.** It reuses the `GhostGlyph` component (form + hue) already used for entities — on-brand, no upload pipeline, no moderation surface, no EXIF. The columns are shaped so a future `avatar_url` can be added without changing anything else. ## Rank (Workstream P) New `backend/app/rank.py`, pure and unit-tested — no DB, no I/O: - `encounter_count` = number of distinct entities the user has contacted (count of their `EntitySighting` rows, distinct on entity_id). - `level_for(encounters, essence, favor) -> int`, and `progress_for(...) -> {level, title, encounters, next_at, progress}`. - Curve: levels get progressively harder; use a documented formula (e.g. thresholds growing ~1.6x) rather than a magic table, and explain the reasoning in a comment. Level 1 must be reachable from a single séance so a new hunter sees progress immediately. - Titles per band, in-fiction and short: e.g. `curious` → `sensitive` → `channeler` → `medium` → `adept` → `oracle`. Exact wording is the implementer's call; keep it to one word each. - Pure functions only — clamped, total, and safe for absurd inputs (negative essence, huge counts). ## Workstream P — model, rank, and the profile API Files: `models/user.py`, `main.py` (migration lines only), `rank.py`, `routes/profile.py` (new), `schemas.py`, tests. Endpoints: - `PATCH /api/profile` (auth): update display_name, bio, gender, avatar_form, avatar_hue, profile_public, email. Validate everything — length caps, gender/form enums, hue range, email shape (reuse the regex style in `routes/shop.py`). Rate limit modestly. - `GET /api/profile/me` (auth): the full own-profile including email and rank/progress. - `GET /api/hunters/{username}` (public, no auth): public profile — display_name, bio, gender, avatar, level/title, encounter count, joined date, and up to 12 recently-contacted entities (name/epithet/rarity/ visual, via EntitySighting joined to Entity, newest first, distinct). **Must 404 when `profile_public` is False.** Never include email. - `GET /api/hunters` (public): a simple roster — top ~24 hunters by encounter count, public profiles only, for the social page. Efficiency: no N+1. Encounter counts for the roster must come from one aggregate query, not per-user lookups. Tests: guests unaffected; email never leaks on public endpoints; private profile 404s; validation rejects bad enums/lengths/hues; rank maths; roster excludes private profiles and doesn't N+1. ## Workstream Q — profile UI Files: `pages/ProfilePage.tsx` + css (own profile editor, route `/profile`), `pages/HunterPage.tsx` + css (public profile, route `/hunters/:username`), `pages/HuntersPage.tsx` + css (roster, route `/hunters`), `App.tsx` routes, one topbar link. - Own profile: edit display name, bio (with live char count), gender (three chips), avatar picker (4 forms x a hue slider, previewing the real `GhostGlyph` live), public/private toggle, optional email field with a clear "recovery only, never shown" note. Save via `PATCH /api/profile`, optimistic-free (await the response, show a saved state). - Public profile: big glyph, display name, title + level with a progress bar, encounter count, bio, and the recent-entity grid (reuse the codex card styling). - Roster: grid of hunter cards linking to each profile. - Guests (`wanderer-` prefix): show the profile page but with a clear in-fiction prompt to claim a name first, rather than hiding it. - All three pages must work at 390px, 44px touch targets, and follow the SeancePage token palette. ## Workstream R — rank surfacing in the séance Files: a small `components/HunterRank.tsx` + css, one mount line in `SeancePage.tsx`; consumes `GET /api/profile/me`. - Compact rank chip in the séance topbar: title, level, and a thin progress bar toward the next level. - On level-up (level higher than the last value seen this session), a brief in-fiction flourish — respect `prefers-reduced-motion`. - Guests see the chip with a "claim a name to keep this" hint. - Never blocks or errors the séance; renders nothing if the fetch fails. ## Integration (controller) Merge order: P, then Q and R. Controller resolves `App.tsx` / `SeancePage` overlaps, runs both full suites + the i18n gate, builds, deploys, verifies the live endpoints, and commits per workstream.