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>
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 BOTHsrc/i18n/en.jsonandes.json.npm run pretestmust 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 tousername.bio: str | None(<= 280 chars) — optional, public.gender: str | None— one ofmale/female/unspecified; defaults tounspecifiedwhen absent. Public.avatar_form: str | None— one of the existing GhostForm values (wisp/banshee/fairy/shade).avatar_hue: int | None— 0-359.profile_public: booldefaultTrue— 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 theirEntitySightingrows, distinct on entity_id).level_for(encounters, essence, favor) -> int, andprogress_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 inroutes/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 whenprofile_publicis 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
GhostGlyphlive), public/private toggle, optional email field with a clear "recovery only, never shown" note. Save viaPATCH /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.