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>
This commit is contained in:
Indiana
2026-07-29 08:36:23 +00:00
parent 7d3a6e695d
commit 94c283634f
2 changed files with 534 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
# 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.