Files
qtalker---/docs/superpowers/specs/2026-07-28-hunter-profiles-design.md
Indiana 94c283634f feat: haunted geography — real places near the seeker
app/haunts.py merges two free, keyless, properly-licensed APIs rather than
scraping: Wikipedia geosearch+extracts (CC BY-SA) and OSM Overpass (ODbL).
Every haunt carries its source and a link back.

The Wikipedia-article requirement doubles as a notability gate: no article,
no pin. That keeps the map to documented history rather than rumour and
makes every entry independently checkable.

Deliberately excluded — recent crimes at residential addresses. People live
in those houses now and get harassed; the families are usually still alive.
So crime-framed entries must clear HISTORICAL_CUTOFF_YEAR, anything
residential is blurred to ~250m (street, never a door number), and an entry
that reads as a crime with no legible date is excluded rather than assumed
old. Battlefields, plague pits, gaols, executions and famous historical
cases are unaffected.

Privacy: the seeker's exact coordinate never leaves the process. Queries
snap to a ~1km grid before going upstream — far finer than the search
radius, coarse enough that Wikipedia and OSM never learn where anyone is,
and it makes the cache shared across a neighbourhood.

Two bugs found and fixed by testing against the live services rather than
assuming:
- Overpass answered 504. The naive query built 28 separate `around:`
  searches (14 kinds x 2 element types); regrouping to one regex-alternated
  clause per tag key with `nwr` cuts it to four.
- The flat keyword filter put "Fenchurch Street railway station" on the map
  because its article mentions a fire. Hints are now split into strong
  (qualify alone) and weak (need two), verified against live results.

Known limitation, honestly: all three public Overpass mirrors currently
time out or return empty from this host, so the map is Wikipedia-only in
practice right now. fetch_overpass already returns [] on any failure, so
this degrades quietly and self-heals if a mirror recovers.

Also adds the hunter-profiles contract spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 08:36:23 +00:00

6.2 KiB

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/<file> -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.