Files
qtalker---/backend/app/rank.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

115 lines
4.2 KiB
Python

"""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),
}