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>
115 lines
4.2 KiB
Python
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),
|
|
}
|