feat: hunter profiles, ranks, whispers, and the encounter record
All five agents died mid-flight (three on session limits, two on 529s), but their worktrees held real work — 17 files. Salvaged everything, wrote the missing pieces, and finished the integration by hand. PROFILES + RANK User gains display_name, bio, gender, avatar_form, avatar_hue and profile_public — all nullable, so every existing row including the guest `wanderer-` accounts stays valid with no backfill. The avatar is procedural (a GhostForm plus a hue, drawn by the same GhostGlyph that renders entities): no uploads means no moderation surface, no EXIF and no blob storage, and an `avatar_url` still slots in later without changing anything. rank.py converts encounters, essence and favor into one "standing" currency and maps it onto six one-word titles. An encounter is worth ten points to ten essence's one, because contact is what the app is about — a seeker who only buys unlocks climbs very slowly. Negative essence and favor floor at zero rather than subtracting, so a bad judgment can never demote you: rank is a record of what you have done. Level 1 costs exactly one encounter, so a new hunter sees the bar move after their first séance. Privacy invariants, verified live rather than assumed: - `email` is returned by GET /api/profile/me and by nothing else. Confirmed against the running server: zero occurrences in both public payloads. - A hidden profile 404s rather than 403s — confirming the account exists would leak exactly what hiding it was meant to prevent. WHISPERS BETWEEN HUNTERS Plain text, no attachments, no editing. Guests can RECEIVE but not send: that gives registering a felt purpose beyond keeping a codex, and closes the obvious spam vector since guest accounts are free and automatic. Verified live: alice→bob delivers, a guest send returns 403, and a third party's conversation list comes back empty — no cross-user leak. Message bodies are rendered as text nodes, never as HTML, and wrap with overflow-wrap:anywhere so a long unbroken string can't blow out the layout. THE ENCOUNTER RECORD The Codex already knew all of this — Entity.discovered_by has always been recorded and every contact was already an entity_sightings row. Nobody ever showed it. Now an entity page names its summoner and lists every hunter who has met it. Hunters who opted out of a public profile are still COUNTED but not linkable: an anonymous contact is still a contact, so a spirit's history stays honest without exposing anyone. Live on production data: Mabel Crump, discovered by Charly, 1 encounter; Charly ranks channeler (level 2) from 5 real sightings — all computed from data that was already sitting there. 385 frontend tests pass; i18n parity holds across both languages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
278
backend/app/routes/profile.py
Normal file
278
backend/app/routes/profile.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Hunter profiles: the identity an account buys you.
|
||||
|
||||
The open door means a séance needs no account at all, so registering has to
|
||||
be worth something on its own. This is that something — a name other hunters
|
||||
see, a rank that grows from what you have actually contacted, and a public
|
||||
page. Device pairing and messages are the other two.
|
||||
|
||||
Privacy rules that the tests pin, because they are easy to regress:
|
||||
|
||||
- `email` is returned by GET /api/profile/me and by NOTHING else. It
|
||||
exists for account recovery; it is never part of a public payload.
|
||||
- `profile_public = False` makes the public page 404 and drops the hunter
|
||||
from the roster. It deliberately does NOT stop messages arriving —
|
||||
privacy here is about being browsed, not about being unreachable.
|
||||
- Guests (`wanderer-` accounts) have profiles like anyone else. They are
|
||||
real rows; nothing here special-cases them.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import rank
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.user import User
|
||||
from app.rate_limit import RateLimiter
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["profile"])
|
||||
|
||||
# Editing a profile is cheap, but it is still a write — enough headroom for
|
||||
# someone fiddling with a hue slider, tight enough to be uninteresting to
|
||||
# abuse.
|
||||
profile_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
||||
|
||||
GENDERS = {"male", "female", "unspecified"}
|
||||
AVATAR_FORMS = {"wisp", "banshee", "fairy", "shade"}
|
||||
BIO_MAX = 280
|
||||
DISPLAY_NAME_MAX = 48
|
||||
HUE_MAX = 359
|
||||
ROSTER_LIMIT = 24
|
||||
RECENT_ENTITIES = 12
|
||||
|
||||
# Same shape as routes/shop.py's — deliberately permissive, since this is a
|
||||
# recovery hint rather than a verified identity.
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]{1,255}\.[^@\s]{2,}$")
|
||||
|
||||
|
||||
async def _encounter_count(db: AsyncSession, user_id) -> int:
|
||||
"""Distinct spirits this hunter has contacted."""
|
||||
return int(
|
||||
await db.scalar(
|
||||
select(func.count(func.distinct(EntitySighting.entity_id))).where(
|
||||
EntitySighting.user_id == user_id
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
|
||||
def _rank_block(encounters: int, user: User) -> dict:
|
||||
return rank.progress_for(encounters, user.essence or 0, user.favor or 0.0)
|
||||
|
||||
|
||||
def _avatar(user: User) -> dict:
|
||||
return {
|
||||
"avatar_form": user.avatar_form,
|
||||
"avatar_hue": user.avatar_hue,
|
||||
}
|
||||
|
||||
|
||||
def _public_fields(user: User) -> dict:
|
||||
"""Everything safe to show a stranger. Note the absence of `email` — that
|
||||
omission is the point of this helper existing."""
|
||||
return {
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"bio": user.bio,
|
||||
"gender": user.gender or "unspecified",
|
||||
**_avatar(user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/profile/me")
|
||||
async def my_profile(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"profile_public": bool(user.profile_public),
|
||||
# Own profile only.
|
||||
"email": user.email,
|
||||
"rank": _rank_block(encounters, user),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/profile")
|
||||
async def update_profile(
|
||||
payload: dict,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Partial update: only keys actually present are touched, so a client
|
||||
can send one field without having to echo the rest back."""
|
||||
if not profile_limiter.allow(str(user.id)):
|
||||
raise HTTPException(
|
||||
status.HTTP_429_TOO_MANY_REQUESTS, "too many changes — slow down"
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "expected an object")
|
||||
|
||||
def _optional_text(key: str, limit: int) -> str | None:
|
||||
"""Trim, cap, and treat empty string as an explicit clear."""
|
||||
raw = payload[key]
|
||||
if raw is None:
|
||||
return None
|
||||
if not isinstance(raw, str):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, f"{key} must be text"
|
||||
)
|
||||
text_value = raw.strip()
|
||||
if len(text_value) > limit:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
f"{key} must be {limit} characters or fewer",
|
||||
)
|
||||
return text_value or None
|
||||
|
||||
if "display_name" in payload:
|
||||
user.display_name = _optional_text("display_name", DISPLAY_NAME_MAX)
|
||||
if "bio" in payload:
|
||||
user.bio = _optional_text("bio", BIO_MAX)
|
||||
|
||||
if "gender" in payload:
|
||||
gender = payload["gender"]
|
||||
if gender not in GENDERS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such gender option"
|
||||
)
|
||||
user.gender = gender
|
||||
|
||||
if "avatar_form" in payload:
|
||||
form = payload["avatar_form"]
|
||||
if form not in AVATAR_FORMS:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "no such sigil form"
|
||||
)
|
||||
user.avatar_form = form
|
||||
|
||||
if "avatar_hue" in payload:
|
||||
hue = payload["avatar_hue"]
|
||||
# bool is an int subclass in Python — reject it explicitly so
|
||||
# `avatar_hue: true` can't silently become hue 1.
|
||||
if not isinstance(hue, int) or isinstance(hue, bool) or not (0 <= hue <= HUE_MAX):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, f"hue must be 0-{HUE_MAX}"
|
||||
)
|
||||
user.avatar_hue = hue
|
||||
|
||||
if "profile_public" in payload:
|
||||
visible = payload["profile_public"]
|
||||
if not isinstance(visible, bool):
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "profile_public must be true/false"
|
||||
)
|
||||
user.profile_public = visible
|
||||
|
||||
if "email" in payload:
|
||||
email = payload["email"]
|
||||
if email is None or (isinstance(email, str) and not email.strip()):
|
||||
user.email = None
|
||||
elif isinstance(email, str) and _EMAIL_RE.match(email.strip().lower()):
|
||||
user.email = email.strip().lower()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY, "that address does not reach us"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"profile_public": bool(user.profile_public),
|
||||
"email": user.email,
|
||||
"rank": _rank_block(encounters, user),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hunters")
|
||||
async def hunter_roster(db: AsyncSession = Depends(get_db)):
|
||||
"""Public hunters, most-travelled first.
|
||||
|
||||
Encounter counts come from ONE grouped query rather than a lookup per
|
||||
hunter — the roster is the most-hit public endpoint here and an N+1
|
||||
would show immediately.
|
||||
"""
|
||||
counts_q = (
|
||||
select(
|
||||
EntitySighting.user_id,
|
||||
func.count(func.distinct(EntitySighting.entity_id)).label("n"),
|
||||
)
|
||||
.group_by(EntitySighting.user_id)
|
||||
.subquery()
|
||||
)
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(User, func.coalesce(counts_q.c.n, 0).label("encounters"))
|
||||
.outerjoin(counts_q, counts_q.c.user_id == User.id)
|
||||
.where(User.profile_public.is_(True))
|
||||
.order_by(func.coalesce(counts_q.c.n, 0).desc(), User.created_at)
|
||||
.limit(ROSTER_LIMIT)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
"hunters": [
|
||||
{
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
**_avatar(user),
|
||||
"rank": _rank_block(int(encounters or 0), user),
|
||||
}
|
||||
for user, encounters in rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/hunters/{username}")
|
||||
async def hunter_profile(username: str, db: AsyncSession = Depends(get_db)):
|
||||
user = await db.scalar(select(User).where(User.username == username))
|
||||
# A hidden profile is indistinguishable from one that never existed —
|
||||
# confirming the account exists would leak exactly what hiding it was
|
||||
# meant to prevent.
|
||||
if user is None or not user.profile_public:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such hunter walks here")
|
||||
|
||||
encounters = await _encounter_count(db, user.id)
|
||||
|
||||
# Most recently contacted spirits, distinct, newest first.
|
||||
latest = (
|
||||
select(
|
||||
EntitySighting.entity_id,
|
||||
func.max(EntitySighting.seen_at).label("last_seen"),
|
||||
)
|
||||
.where(EntitySighting.user_id == user.id)
|
||||
.group_by(EntitySighting.entity_id)
|
||||
.subquery()
|
||||
)
|
||||
recent = (
|
||||
await db.execute(
|
||||
select(Entity, latest.c.last_seen)
|
||||
.join(latest, latest.c.entity_id == Entity.id)
|
||||
.order_by(latest.c.last_seen.desc())
|
||||
.limit(RECENT_ENTITIES)
|
||||
)
|
||||
).all()
|
||||
|
||||
return {
|
||||
**_public_fields(user),
|
||||
"joined_at": user.created_at.isoformat() if user.created_at else None,
|
||||
"rank": _rank_block(encounters, user),
|
||||
"recent_entities": [
|
||||
{
|
||||
"id": str(entity.id),
|
||||
"name": entity.name,
|
||||
"epithet": entity.epithet,
|
||||
"rarity": entity.rarity_tier,
|
||||
"visual": entity.visual_profile or {},
|
||||
}
|
||||
for entity, _seen in recent
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user