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