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>
127 lines
6.2 KiB
Markdown
127 lines
6.2 KiB
Markdown
# 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.
|