Files
qtalker---/backend/app/schemas.py
Indiana bacfb852b8 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>
2026-07-31 02:50:00 +00:00

72 lines
1.5 KiB
Python

import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=32)
password: str = Field(min_length=8, max_length=128)
email: str | None = None
class UserOut(BaseModel):
id: uuid.UUID
username: str
essence: int = 0
unlocks: list[str] = Field(default_factory=list)
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
username: str
password: str
class UnlockOut(BaseModel):
unlock_key: str
unlocked_at: datetime
model_config = ConfigDict(from_attributes=True)
class PurchaseOut(BaseModel):
unlock_key: str
unlocked_at: datetime
essence: int
class InventoryItemOut(BaseModel):
id: uuid.UUID
item_type: str
item_key: str
payload: dict
obtained_at: datetime
model_config = ConfigDict(from_attributes=True)
class SigilIn(BaseModel):
name: str = Field(min_length=1, max_length=64)
design: dict
class SigilOut(BaseModel):
id: uuid.UUID
name: str
design: dict
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MessageIn(BaseModel):
"""A whisper sent from one hunter to another (Workstream S)."""
# Length rules live in routes/messages.py, not here: an empty or
# oversized body is a 400 with in-fiction copy the UI can show, which
# is friendlier than Pydantic's 422 validation envelope.
to: str
body: str