Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions backend/app/routers/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
18 changes: 18 additions & 0 deletions backend/app/services/run_entity_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

Expand Down
54 changes: 54 additions & 0 deletions backend/tests/test_metrics_character_runs.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading