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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user