diff --git a/backend/app/routers/runs.py b/backend/app/routers/runs.py index 54888ed7..622a50cb 100644 --- a/backend/app/routers/runs.py +++ b/backend/app/routers/runs.py @@ -1244,13 +1244,29 @@ def get_entity_metrics( `bracket` slices to a pre-built run bracket: `all` (default), `solo`, `2p`, `3p`, `4p`, `a10` (ascension 10), `daily`, `custom`, plus the - win-rate skill tiers `wr30`/`wr50`/`wr75`. Every bracket is materialized - in the same snapshot, so this stays a single cached read. `cohort` is a - deprecated alias for `bracket` (the param was renamed). + win-rate skill tiers `wr30`/`wr50`/`wr75`. Brackets also compose with + game versions: a bare version is a bracket (`?bracket=v0.110.0`), and + any key composes with one (`?bracket=solo:wr30:v0.110.0`); the versions + the snapshot carries are listed by `/api/runs/versions`, and without one + the table spans every patch. Every bracket is materialized in the same + snapshot, so this stays a single cached read. `cohort` is a deprecated + alias for `bracket` (the param was renamed). + + Source data: every submitted run at Ascension 0-10 played on an official + character, all game modes included unless a mode bracket says otherwise, + minus runs hidden by moderation. The win-rate tiers count A10 runs from + submitters whose lifetime win rate (5-run minimum, on claimed runs) + beats the tier's threshold. `character` (e.g. IRONCLAD) combines with any bracket, including the player x skill composites: `?bracket=solo:a10&character=IRONCLAD` is - Ironclad's solo A10 table. Character rows carry Score and Win% only. + Ironclad's solo A10 table. Character rows carry Score and Win% only; + the reward-preference metrics (Elo, Pick%, offered/picked, per-act) + aren't tracked per character. Character views also return + `character_runs` / `character_wins`, that character's own run count + inside the bracket, next to the bracket-wide `total_runs`, so clients + can show the character's real share. Null when the bracket carries no + per-character split (daily/custom). """ # Back-compat: ?cohort= was renamed to ?bracket=; honor the old name. if cohort is not None and bracket == "all": diff --git a/backend/app/services/run_entity_stats.py b/backend/app/services/run_entity_stats.py index fb75a81a..398e0476 100644 --- a/backend/app/services/run_entity_stats.py +++ b/backend/app/services/run_entity_stats.py @@ -3202,6 +3202,22 @@ def get_entity_metrics_table( _bracket_prior(total_runs) if use_bracket and entity_type == "relics" else None ) + # Character views also report that character's own run count within the + # bracket, so clients get a real denominator (total_runs stays the whole + # bracket). Read from the community blob's by_character split, which the + # snapshot already materializes per bracket; daily/custom carry no + # community blob, so those honestly return null instead of a guess. + character_runs = character_wins = None + if character: + from . import community_stats + + blob = _pick_bracket_blob(_community_stats, bracket, community_stats.empty_one) + for ch in blob.get("by_character") or []: + if ch.get("id") == character.lower(): + character_runs = ch.get("runs") + character_wins = ch.get("wins") + break + # Player=All rows blend the per-player Elos (equal weight) instead of the # pooled fit, so a card's "All players" rating reflects every mode rather # than being dominated by solo volume. Maps the requested bracket to the @@ -3367,6 +3383,8 @@ def _row(eid, picks, wins, *, elo, offered, picked, off_act, pick_act, upgraded) "character": character, "baseline_win_rate": round(baseline * 100, 1), "total_runs": total_runs, + "character_runs": character_runs, + "character_wins": character_wins, "rows": rows, } diff --git a/backend/tests/test_metrics_character_runs.py b/backend/tests/test_metrics_character_runs.py new file mode 100644 index 00000000..915423d4 --- /dev/null +++ b/backend/tests/test_metrics_character_runs.py @@ -0,0 +1,54 @@ +"""?character= filters the metrics rows but total_runs stays the whole +bracket, so clients couldn't tell what share of the bracket that character +actually is. The table now also carries character_runs / character_wins, +read from the community blob's per-bracket by_character split.""" + +from app.services import run_entity_stats as res + + +def _setup(monkeypatch): + monkeypatch.setattr(res, "_maybe_rebuild", lambda: None) + monkeypatch.setattr(res, "_cache", {}) + monkeypatch.setattr( + res, + "_bracket_totals", + {"solo:wr30": {"total_runs": 999, "total_wins": 300}}, + ) + monkeypatch.setattr(res, "_bracket_baselines", {"solo:wr30": {"cards": 0.3}}) + monkeypatch.setattr( + res, + "_community_stats", + { + "all": {"by_character": [{"id": "defect", "runs": 5000, "wins": 1200}]}, + "solo:wr30": {"by_character": [{"id": "defect", "runs": 123, "wins": 45}]}, + }, + ) + + +def test_character_runs_come_from_the_requested_bracket(monkeypatch): + _setup(monkeypatch) + out = res.get_entity_metrics_table("cards", "solo:wr30", "DEFECT") + assert out["total_runs"] == 999 + assert out["character_runs"] == 123 + assert out["character_wins"] == 45 + + +def test_no_character_keeps_the_fields_null(monkeypatch): + _setup(monkeypatch) + out = res.get_entity_metrics_table("cards", "solo:wr30") + assert out["character_runs"] is None + assert out["character_wins"] is None + + +def test_bracket_without_community_blob_is_null_not_wrong(monkeypatch): + # daily/custom never get a community blob, and an unknown character + # must not fall back to some other bracket's numbers. + _setup(monkeypatch) + assert ( + res.get_entity_metrics_table("cards", "daily", "DEFECT")["character_runs"] + is None + ) + assert ( + res.get_entity_metrics_table("cards", "solo:wr30", "WATCHER")["character_runs"] + is None + )