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:
Indiana
2026-07-31 02:50:00 +00:00
parent 3656b6b0c4
commit bacfb852b8
23 changed files with 3472 additions and 4 deletions

View File

@@ -15,6 +15,8 @@ from app.routes.codex import router as codex_router
from app.routes.conditions import router as conditions_router
from app.routes.device import router as device_router
from app.routes.inventory import router as inventory_router
from app.routes.messages import router as messages_router
from app.routes.profile import router as profile_router
from app.routes.seances import router as seances_router
from app.routes.seo import router as seo_router
from app.routes.shop import router as shop_router
@@ -71,6 +73,23 @@ async def lifespan(app: FastAPI):
await conn.execute(text(
"ALTER TABLE entities ADD COLUMN IF NOT EXISTS at_peace BOOLEAN NOT NULL DEFAULT false"
))
# Hunter profile columns. All nullable (or defaulted) so existing
# rows, guests included, stay valid without a backfill.
for column, ddl in (
("display_name", "VARCHAR(48)"),
("bio", "VARCHAR(280)"),
("gender", "VARCHAR(16)"),
("avatar_form", "VARCHAR(16)"),
("avatar_hue", "INTEGER"),
):
await conn.execute(
text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {column} {ddl}")
)
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS "
"profile_public BOOLEAN NOT NULL DEFAULT true"
))
# Defense-in-depth: purchase_unlock() already enforces one row per
# (user, unlock_key) via a row-locked check-then-insert, so this
# constraint should never actually find a conflict on a live DB.
@@ -100,6 +119,8 @@ app.include_router(codex_router)
app.include_router(conditions_router)
app.include_router(device_router)
app.include_router(inventory_router)
app.include_router(messages_router)
app.include_router(profile_router)
app.include_router(seances_router)
# Registered before the SPA catch-all below, or /robots.txt and
# /sitemap.xml would be served index.html instead.

View File

@@ -5,6 +5,7 @@ from app.models.entity import Entity
from app.models.entity_sighting import EntitySighting
from app.models.event import Event
from app.models.inventory_item import InventoryItem
from app.models.message import Message
from app.models.sigil import Sigil
from app.models.unlock import UnlockRecord
from app.models.user import User
@@ -19,6 +20,7 @@ __all__ = [
"EntitySighting",
"Event",
"InventoryItem",
"Message",
"Sigil",
"UnlockRecord",
"WaitlistEntry",

View File

@@ -0,0 +1,49 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
# The wire cap. Enforced in three places on purpose: the Pydantic schema
# (rejects with a clear 422/400 before touching the DB), the route's own
# check (so a body built any other way still can't slip through), and the
# column type below — Text, because Postgres VARCHAR(n) truncation/erroring
# is a worse failure mode than a validated length, and a future cap change
# then needs no migration.
BODY_MAX_CHARS = 1000
class Message(Base):
"""One plain-text message from one hunter to another.
Deliberately minimal (hunters-and-messages spec, Workstream S): no
attachments, no editing, no groups, no threads-as-rows — a "thread" is
simply every row between two user ids, ordered by time.
`read_at` is null until the *recipient* opens the thread; the sender
never marks anything read, so this doubles as the unread signal.
"""
__tablename__ = "messages"
# Both directions are queried: the conversation list and thread view each
# need "sent by me" OR'd with "sent to me", and the unread aggregate
# scans (recipient_id, read_at). The composite indexes below serve those
# ordered scans; the per-column indexes on the FKs come from
# index=True and keep single-sided lookups cheap.
__table_args__ = (
Index("ix_messages_sender_recipient_created", "sender_id", "recipient_id", "created_at"),
Index("ix_messages_recipient_sender_created", "recipient_id", "sender_id", "created_at"),
)
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
sender_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
recipient_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
body: Mapped[str] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), index=True
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)

View File

@@ -1,7 +1,7 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Integer, String
from sqlalchemy import Boolean, DateTime, Float, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db import Base
@@ -14,6 +14,27 @@ class User(Base):
username: Mapped[str] = mapped_column(String(32), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
# --- hunter profile ---------------------------------------------------
# All nullable so every pre-existing row — including the guest
# `wanderer-` accounts minted by the open door — stays valid without a
# backfill. Absent values are treated as "not set" and fall back at the
# serialization layer, never here.
#
# The avatar is procedural: a GhostForm plus a hue, rendered by the same
# GhostGlyph component that draws entities. No uploads means no
# moderation surface, no EXIF stripping and no blob storage — and if a
# real image is ever wanted, an `avatar_url` slots in beside these
# without changing anything else.
display_name: Mapped[str | None] = mapped_column(String(48), nullable=True)
bio: Mapped[str | None] = mapped_column(String(280), nullable=True)
gender: Mapped[str | None] = mapped_column(String(16), nullable=True)
avatar_form: Mapped[str | None] = mapped_column(String(16), nullable=True)
avatar_hue: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Hides the profile from /api/hunters and 404s the public page. It does
# NOT stop messages arriving — privacy here is about being browsed, not
# about being unreachable.
profile_public: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
essence: Mapped[int] = mapped_column(Integer, default=0)
# Workstream B (character-depth-ghost-log spec): hidden per-user score,
# nudged by judgment correctness, clamped to [-1.0, 1.0] everywhere it's

114
backend/app/rank.py Normal file
View File

@@ -0,0 +1,114 @@
"""A hunter's rank — pure maths over three numbers already on the User row.
No DB, no I/O, no clock: `level_for` and `progress_for` are total functions of
their arguments, so the API layer can call them on values it already loaded and
the tests can exercise absurd inputs without a database.
The curve
---------
Everything is converted into one currency, "standing", so the three sources of
progress can be compared:
standing = encounters * ENCOUNTER_WEIGHT
+ max(0, essence) // ESSENCE_PER_POINT
+ max(0.0, favor) * FAVOR_WEIGHT
Contact is what the app is *about*, so an encounter is worth ten points while
ten essence is worth one — a seeker who only buys unlocks climbs very slowly.
Favor is a hidden [-1, 1] score nudged by judgment correctness; it contributes
at most a few points, enough to break a tie between two equally-travelled
hunters but never enough to be a second progression track. Negative essence and
negative favor are floored at zero rather than subtracting, so a hunter can
never be *demoted* by a bad judgment — rank is a record of what you have done.
Thresholds are `0, 10, 40, 100, 220, 450`: level 1 costs exactly one encounter
(a new hunter finishes their first séance and immediately sees the bar move —
this is the point of the curve), then each step costs roughly 2.2x the last.
Geometric growth means the early levels arrive in a single sitting while
`oracle` is a genuine long-haul goal (~45 distinct spirits), without a
hand-tuned table that has to be re-justified every time a level is added.
"""
# One-word, in-fiction titles, indexed by level.
TITLES: tuple[str, ...] = (
"curious",
"sensitive",
"channeler",
"medium",
"adept",
"oracle",
)
# Standing required to *reach* each level; index == level. Strictly increasing.
THRESHOLDS: tuple[int, ...] = (0, 10, 40, 100, 220, 450)
MAX_LEVEL = len(THRESHOLDS) - 1
ENCOUNTER_WEIGHT = 10
ESSENCE_PER_POINT = 10
FAVOR_WEIGHT = 5.0
def standing_for(encounters: int, essence: int, favor: float) -> int:
"""The single progression currency. Total and non-negative for any input,
including negative essence/favor and non-finite favor."""
try:
enc = max(0, int(encounters))
ess = max(0, int(essence))
fav = float(favor)
except (TypeError, ValueError):
return 0
# NaN fails every comparison, so test for it rather than clamping.
if not (fav == fav): # noqa: PLR0124 — NaN check without importing math
fav = 0.0
fav = min(1.0, max(0.0, fav))
return enc * ENCOUNTER_WEIGHT + ess // ESSENCE_PER_POINT + int(fav * FAVOR_WEIGHT)
def level_for(encounters: int, essence: int, favor: float) -> int:
"""Highest level whose threshold the hunter's standing has reached,
clamped to [0, MAX_LEVEL]."""
standing = standing_for(encounters, essence, favor)
level = 0
for candidate, threshold in enumerate(THRESHOLDS):
if standing >= threshold:
level = candidate
else:
break
return level
def title_for(level: int) -> str:
"""Title for a level, clamped — never raises on an out-of-range level."""
return TITLES[min(MAX_LEVEL, max(0, int(level)))]
def progress_for(encounters: int, essence: int, favor: float) -> dict:
"""Rank plus the numbers a progress bar needs.
`next_at` is the standing required for the next level (None at MAX_LEVEL),
and `progress` is the 0.0..1.0 fraction of the way there (1.0 at
MAX_LEVEL, so a maxed bar renders full rather than empty).
"""
standing = standing_for(encounters, essence, favor)
level = level_for(encounters, essence, favor)
floor = THRESHOLDS[level]
if level >= MAX_LEVEL:
next_at: int | None = None
progress = 1.0
else:
next_at = THRESHOLDS[level + 1]
span = next_at - floor
progress = min(1.0, max(0.0, (standing - floor) / span))
return {
"level": level,
"title": title_for(level),
# Echoed back so a caller rendering the bar doesn't need a second
# source for the count it is labelling.
"encounters": max(0, int(encounters)) if isinstance(encounters, (int, float)) else 0,
"standing": standing,
"next_at": next_at,
"progress": round(progress, 4),
}

View File

@@ -16,6 +16,97 @@ from app.models.user import User
router = APIRouter(prefix="/api", tags=["codex"])
# How many distinct hunters the dossier names. Everyone else is still
# counted in `total_encounters` — the roster is a window, not the truth.
ROSTER_LIMIT = 20
def _user_column(name: str):
"""The profile columns land in `users` via a parallel workstream (the
hunter-profiles spec §"Data model"). Until that migration exists this
module must not reference them in SQL at all, or every Codex request
500s on an unknown column. Resolve them by name and fall back to a
literal default, so the same code path works before and after.
"""
return getattr(User, name, None)
def _hunter_entry(
username: str,
display_name: str | None,
avatar_form: str | None,
avatar_hue: int | None,
profile_public: bool | None,
times: int,
last_seen,
) -> dict:
# A hidden profile is still a hunter who was there: counted, named
# nowhere. `public` is what the UI keys the link off.
public = True if profile_public is None else bool(profile_public)
return {
"username": username,
"display_name": display_name or username,
"avatar_form": avatar_form,
"avatar_hue": avatar_hue,
"public": public,
"times_contacted": times,
"last_seen": last_seen.isoformat() if last_seen else None,
}
async def _encounters(db: AsyncSession, entity_id: uuid.UUID) -> tuple[list[dict], int]:
"""The roster of hunters who have contacted this spirit, newest-first.
ONE aggregate query — group the sightings by hunter and carry the count
and latest contact out of the same scan. Never a lookup per hunter.
"""
display_name = _user_column("display_name")
avatar_form = _user_column("avatar_form")
avatar_hue = _user_column("avatar_hue")
profile_public = _user_column("profile_public")
last_seen = func.max(EntitySighting.seen_at).label("last_seen")
times = func.count(EntitySighting.id).label("times")
columns = [User.username, times, last_seen]
optional = [display_name, avatar_form, avatar_hue, profile_public]
columns.extend(col for col in optional if col is not None)
rows = (
await db.execute(
select(*columns)
.join(User, User.id == EntitySighting.user_id)
.where(EntitySighting.entity_id == entity_id)
.group_by(User.id)
.order_by(desc(last_seen))
.limit(ROSTER_LIMIT)
)
).all()
roster = [
_hunter_entry(
row.username,
getattr(row, "display_name", None) if display_name is not None else None,
getattr(row, "avatar_form", None) if avatar_form is not None else None,
getattr(row, "avatar_hue", None) if avatar_hue is not None else None,
getattr(row, "profile_public", None) if profile_public is not None else None,
row.times,
row.last_seen,
)
for row in rows
]
# Distinct hunters, not sighting rows (`sightings` already reports those)
# — so the UI can honestly say "and N more" past the roster window.
total = (
await db.scalar(
select(func.count(func.distinct(EntitySighting.user_id))).where(
EntitySighting.entity_id == entity_id
)
)
or 0
)
return roster, total
def _entity_card(entity: Entity, discoverer: str | None) -> dict:
return {
@@ -72,17 +163,45 @@ async def get_entity(entity_id: uuid.UUID, db: AsyncSession = Depends(get_db)):
raise HTTPException(status.HTTP_404_NOT_FOUND, "no such spirit in the codex")
discoverer = None
discoverer_entry = None
if entity.discovered_by:
user = await db.get(User, entity.discovered_by)
discoverer = user.username if user else None
if user is not None:
discoverer = user.username
discoverer_entry = _hunter_entry(
user.username,
getattr(user, "display_name", None),
getattr(user, "avatar_form", None),
getattr(user, "avatar_hue", None),
getattr(user, "profile_public", None),
0,
None,
)
sightings = await db.scalar(
select(func.count(EntitySighting.id)).where(EntitySighting.entity_id == entity.id)
)
roster, total_encounters = await _encounters(db, entity.id)
card = _entity_card(entity, discoverer)
card["persona"] = entity.persona
card["voice"] = entity.voice_profile
card["sightings"] = sightings or 0
# The summoner, with enough to render a glyph and decide on a link.
# `times_contacted`/`last_seen` come from their roster row when they
# have one — discovery alone doesn't imply a surviving sighting row.
if discoverer_entry is not None:
for hunter in roster:
if hunter["username"] == discoverer_entry["username"]:
discoverer_entry["times_contacted"] = hunter["times_contacted"]
discoverer_entry["last_seen"] = hunter["last_seen"]
break
card["discoverer"] = discoverer_entry
card["discoverer_public"] = (
discoverer_entry["public"] if discoverer_entry else False
)
card["encounters"] = roster
card["total_encounters"] = total_encounters
return card

View File

@@ -0,0 +1,254 @@
"""Whispers between hunters — plain text, no attachments, no editing, no
groups. Workstream S of docs/superpowers/specs/
2026-07-30-hunters-and-messages-design.md.
Security note, because it is the whole point of this module: every query is
scoped to the caller's own user id. A "thread" is not a row anybody can
address by id — it is derived from (sender_id, recipient_id) pairs where one
side is always `user.id`, so there is no id a caller could guess to read
somebody else's mail. See tests/test_messages.py's cross-user leak tests.
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import case, desc, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.deps import get_current_user
from app.models.message import BODY_MAX_CHARS, Message
from app.models.user import User
from app.routes.auth import WANDERER_PREFIX
from app.rate_limit import RateLimiter
from app.schemas import MessageIn
router = APIRouter(prefix="/api/messages", tags=["messages"])
# Per-user, not per-IP: sending requires an account, so the user id is the
# real actor. 20 an hour is plenty for conversation and useless for spam.
send_limiter = RateLimiter(max_requests=20, window_seconds=3600)
CONVERSATION_LIMIT = 50
THREAD_LIMIT_DEFAULT = 50
THREAD_LIMIT_MAX = 100
EXCERPT_CHARS = 160
AVATAR_FORMS = ("wisp", "banshee", "fairy", "shade")
def _hunter_out(user: User) -> dict:
"""Public identity of a correspondent.
Workstream P owns `display_name` / `avatar_form` / `avatar_hue` /
`profile_public` and lands separately, so every one is read with
`getattr(..., None)`: this module works identically before and after
that merge, and the mailbox never depends on a profile being public —
privacy hides the profile page, not the mailbox.
"""
form = getattr(user, "avatar_form", None)
hue = getattr(user, "avatar_hue", None)
return {
"username": user.username,
"display_name": getattr(user, "display_name", None) or user.username,
"avatar": {
"form": form if form in AVATAR_FORMS else "wisp",
"hue": hue if isinstance(hue, int) and 0 <= hue <= 359 else 150,
},
# False only when Workstream P exists AND the hunter opted out; the
# UI uses this purely to decide whether to link to their profile.
"profile_public": getattr(user, "profile_public", True) is not False,
"is_wanderer": user.username.startswith(WANDERER_PREFIX),
}
def _message_out(message: Message, me_id) -> dict:
return {
"id": str(message.id),
"body": message.body,
"from_me": message.sender_id == me_id,
"created_at": message.created_at.isoformat(),
"read_at": message.read_at.isoformat() if message.read_at else None,
}
async def _unread_by_sender(db: AsyncSession, me_id) -> dict:
"""ONE aggregate query for every unread count — never a lookup per
conversation. Keyed by the sender's user id."""
rows = await db.execute(
select(Message.sender_id, func.count(Message.id))
.where(Message.recipient_id == me_id, Message.read_at.is_(None))
.group_by(Message.sender_id)
)
return {sender_id: count for sender_id, count in rows}
@router.post("", status_code=status.HTTP_201_CREATED)
async def send_message(
payload: MessageIn,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
# The one deliberate account-only capability besides device pairing: a
# wanderer's name is temporary and unrecoverable, so a reply would have
# nowhere to land. They can still RECEIVE.
if user.username.startswith(WANDERER_PREFIX):
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"a wanderer has no name to sign — claim one before you whisper",
)
body = payload.body.strip()
if not body:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "an empty whisper carries nothing")
if len(body) > BODY_MAX_CHARS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"the veil will not carry more than {BODY_MAX_CHARS} characters at once",
)
to = payload.to.strip()
if to.lower() == user.username.lower():
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
)
recipient = await db.scalar(select(User).where(User.username == to))
if recipient is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
# Guard the id comparison too: the username check above is the friendly
# path, this one is what actually makes self-messaging impossible.
if recipient.id == user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
)
if not send_limiter.allow(str(user.id)):
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
"you have whispered enough for one hour — let the veil settle",
)
message = Message(sender_id=user.id, recipient_id=recipient.id, body=body)
db.add(message)
await db.commit()
await db.refresh(message)
return {"message": _message_out(message, user.id), "to": _hunter_out(recipient)}
@router.get("")
async def list_conversations(
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
):
"""Every correspondent, their last message excerpt, and unread counts.
Two queries total regardless of how many conversations exist: one
window-function pass for the newest message per correspondent, and one
GROUP BY for unread counts.
"""
correspondent = case(
(Message.sender_id == user.id, Message.recipient_id), else_=Message.sender_id
)
ranked = (
select(
Message.id,
Message.sender_id,
Message.body,
Message.created_at,
Message.read_at,
correspondent.label("correspondent_id"),
func.row_number()
.over(partition_by=correspondent, order_by=desc(Message.created_at))
.label("rn"),
)
.where(or_(Message.sender_id == user.id, Message.recipient_id == user.id))
.subquery()
)
rows = (
await db.execute(
select(
ranked.c.sender_id,
ranked.c.body,
ranked.c.created_at,
ranked.c.read_at,
User,
)
.join(User, User.id == ranked.c.correspondent_id)
.where(ranked.c.rn == 1)
.order_by(desc(ranked.c.created_at))
.limit(CONVERSATION_LIMIT)
)
).all()
unread = await _unread_by_sender(db, user.id)
conversations = [
{
"hunter": _hunter_out(other),
"excerpt": body[:EXCERPT_CHARS],
"truncated": len(body) > EXCERPT_CHARS,
"last_at": created_at.isoformat(),
"last_from_me": sender_id == user.id,
"unread": unread.get(other.id, 0),
}
for sender_id, body, created_at, read_at, other in rows
]
return {
"conversations": conversations,
# Exposed here on purpose rather than on GET /api/profile/me, so the
# badge does not depend on Workstream P.
"unread_total": sum(unread.values()),
}
@router.get("/{username}")
async def read_thread(
username: str,
limit: int = Query(default=THREAD_LIMIT_DEFAULT, ge=1, le=THREAD_LIMIT_MAX),
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""The thread with one hunter, newest LAST (reading order), capped at
`limit` most-recent messages. Marks the caller's inbound messages read."""
other = await db.scalar(select(User).where(User.username == username))
if other is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "no hunter answers to that name")
if other.id == user.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "your own echo is not a correspondent"
)
# Both legs are pinned to (user.id, other.id) in both orders — no third
# party's rows can match, whatever `username` is.
scope = or_(
(Message.sender_id == user.id) & (Message.recipient_id == other.id),
(Message.sender_id == other.id) & (Message.recipient_id == user.id),
)
total = await db.scalar(select(func.count(Message.id)).where(scope)) or 0
newest = (
await db.execute(
select(Message).where(scope).order_by(desc(Message.created_at)).limit(limit)
)
).scalars().all()
messages = list(reversed(newest))
# One UPDATE marks everything they sent us read — done after reading the
# rows so the response still shows the read_at the caller had on open.
await db.execute(
update(Message)
.where(
Message.recipient_id == user.id,
Message.sender_id == other.id,
Message.read_at.is_(None),
)
.values(read_at=datetime.now(timezone.utc))
)
await db.commit()
return {
"hunter": _hunter_out(other),
"messages": [_message_out(m, user.id) for m in messages],
"total": total,
"has_more": total > len(messages),
"can_send": not user.username.startswith(WANDERER_PREFIX),
}

View 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
],
}

View File

@@ -59,3 +59,13 @@ class SigilOut(BaseModel):
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