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:
114
backend/app/rank.py
Normal file
114
backend/app/rank.py
Normal 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),
|
||||
}
|
||||
Reference in New Issue
Block a user