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:
@@ -1,9 +1,13 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.contact_session import ContactSession
|
||||
from app.models.entity import Entity
|
||||
from app.models.entity_sighting import EntitySighting
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _make_entity(name="Vesper Wren", rarity="rare", signature="abc123def4567890"):
|
||||
@@ -81,3 +85,112 @@ async def test_stats_counts_veil_activity(client, db_session):
|
||||
async def test_codex_is_public_without_auth(client):
|
||||
assert (await client.get("/api/codex")).status_code == 200
|
||||
assert (await client.get("/api/stats")).status_code == 200
|
||||
|
||||
|
||||
# --- the encounter record: who summoned a spirit, and who else has met it ---
|
||||
# (hunter-profiles wave). The data already existed — every contact is an
|
||||
# EntitySighting row, discovery is Entity.discovered_by.
|
||||
|
||||
|
||||
async def _hunter(db_session, username: str, **profile):
|
||||
"""A hunter. `profile` fields (display_name, profile_public, …) are set
|
||||
only if the parallel profile migration has landed, so these tests pass
|
||||
either way."""
|
||||
user = User(username=username, password_hash="x")
|
||||
for key, value in profile.items():
|
||||
if hasattr(User, key):
|
||||
setattr(user, key, value)
|
||||
db_session.add(user)
|
||||
await db_session.flush()
|
||||
return user
|
||||
|
||||
|
||||
async def _contact(db_session, entity, user, seen_at):
|
||||
session = ContactSession(user_id=user.id, entity_id=entity.id, mode="wire")
|
||||
db_session.add(session)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
EntitySighting(
|
||||
entity_id=entity.id,
|
||||
session_id=session.id,
|
||||
user_id=user.id,
|
||||
seen_at=seen_at,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_surfaces_the_discoverer(client, db_session):
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
summoner = await _hunter(db_session, "mora", display_name="Mora Vane")
|
||||
await db_session.flush()
|
||||
entity.discovered_by = summoner.id
|
||||
await _contact(
|
||||
db_session, entity, summoner, datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["discovered_by"] == "mora"
|
||||
assert body["discoverer"]["username"] == "mora"
|
||||
assert body["discoverer"]["times_contacted"] == 1
|
||||
assert body["discoverer"]["last_seen"] is not None
|
||||
# Public by default, so the dossier may link to their hunter page.
|
||||
assert body["discoverer_public"] is True
|
||||
assert body["discoverer"]["display_name"] in {"mora", "Mora Vane"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_encounter_roster_aggregates_per_hunter(client, db_session):
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
twice = await _hunter(db_session, "reva")
|
||||
once = await _hunter(db_session, "isolde")
|
||||
await _contact(db_session, entity, twice, datetime(2026, 7, 1, tzinfo=timezone.utc))
|
||||
await _contact(db_session, entity, twice, datetime(2026, 7, 2, tzinfo=timezone.utc))
|
||||
await _contact(db_session, entity, once, datetime(2026, 7, 9, tzinfo=timezone.utc))
|
||||
await db_session.commit()
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["sightings"] == 3
|
||||
# Distinct hunters, not sighting rows.
|
||||
assert body["total_encounters"] == 2
|
||||
roster = body["encounters"]
|
||||
assert [h["username"] for h in roster] == ["isolde", "reva"] # newest first
|
||||
by_name = {h["username"]: h for h in roster}
|
||||
assert by_name["reva"]["times_contacted"] == 2
|
||||
assert by_name["isolde"]["times_contacted"] == 1
|
||||
assert by_name["reva"]["last_seen"].startswith("2026-07-02")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_private_hunters_are_counted_but_not_linkable(client, db_session):
|
||||
if not hasattr(User, "profile_public"):
|
||||
pytest.skip("profile_public lands with the profile workstream")
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
hidden = await _hunter(db_session, "nocturne", profile_public=False)
|
||||
seen = await _hunter(db_session, "calla", profile_public=True)
|
||||
await _contact(db_session, entity, hidden, datetime(2026, 7, 3, tzinfo=timezone.utc))
|
||||
await _contact(db_session, entity, seen, datetime(2026, 7, 4, tzinfo=timezone.utc))
|
||||
await db_session.commit()
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["total_encounters"] == 2
|
||||
flags = {h["username"]: h["public"] for h in body["encounters"]}
|
||||
assert flags == {"nocturne": False, "calla": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unmet_spirit_has_an_empty_roster(client, db_session):
|
||||
entity = _make_entity()
|
||||
db_session.add(entity)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(entity)
|
||||
|
||||
body = (await client.get(f"/api/codex/{entity.id}")).json()
|
||||
assert body["encounters"] == []
|
||||
assert body["total_encounters"] == 0
|
||||
assert body["discoverer"] is None
|
||||
assert body["discoverer_public"] is False
|
||||
|
||||
Reference in New Issue
Block a user