From 97cee2e52bb4c29be9fe4bc43c10fa52117aba00 Mon Sep 17 00:00:00 2001 From: Peter Lord Date: Fri, 10 Jul 2026 10:04:18 -0700 Subject: [PATCH] Let community stats combine player count and skill brackets Add the player x skill composite brackets (solo:wr50, ...) to the community blob so /community-stats can combine both axes at once, like the tier list, instead of picking only one. The community BracketFilter switches to composite mode; the endpoint accepts the composite bracket values. Snapshot version -> 14 (additive). Also cut the snapshot rebuild frequency: the walk reads every run blob (~750k files, ~80 min on the current box), so the 10-minute rebuild interval had the leader walking almost continuously, starving the DB and slowing the whole site. These stats don't need minute-fresh data, so rebuild every 2 hours instead. This matters more now that the community blob is a bit heavier. --- backend/app/routers/runs.py | 19 +++++++++---------- backend/app/services/community_stats.py | 17 +++++++++++++---- backend/app/services/run_entity_stats.py | 21 +++++++++++++++++---- frontend/app/community-stats/page.tsx | 4 ++-- 4 files changed, 41 insertions(+), 20 deletions(-) diff --git a/backend/app/routers/runs.py b/backend/app/routers/runs.py index 4a09ea7f..158dca7a 100644 --- a/backend/app/routers/runs.py +++ b/backend/app/routers/runs.py @@ -1006,16 +1006,15 @@ def community_stats(request: Request, response: Response, bracket: str | None = `bracket` slices to a content bracket (`a10`, `wr30`, `wr50`, `wr75`); omit for all runs.""" - if bracket is not None and bracket not in ( - "solo", - "2p", - "3p", - "4p", - "a10", - "wr30", - "wr50", - "wr75", - ): + # Accept the player-count buckets, the skill tiers, and their player:skill + # composites (solo:wr50, ...) — the last let the page combine both axes. + _players = ("solo", "2p", "3p", "4p") + _skills = ("a10", "wr30", "wr50", "wr75") + _ok = bracket is None or bracket in _players or bracket in _skills + if not _ok and ":" in (bracket or ""): + _p, _, _s = bracket.partition(":") + _ok = _p in _players and _s in _skills + if not _ok: raise HTTPException(status_code=400, detail="bad bracket") # An empty shell during a post-deploy rebuild must not stick in the # edge cache for 5 minutes on top of the rebuild itself. diff --git a/backend/app/services/community_stats.py b/backend/app/services/community_stats.py index 4003324c..98eba037 100644 --- a/backend/app/services/community_stats.py +++ b/backend/app/services/community_stats.py @@ -72,10 +72,19 @@ def _merge_starter(cid: str | None) -> str | None: COMMUNITY_VERSION = 1 -# Brackets the blob is accumulated per (matches encounter_stats): "all", the -# exact player-count buckets (solo/2p/3p/4p), and the A10-gated win-rate ladder, -# so the community datasets can re-slice by co-op size and by skill. -_BLOB_BRACKETS = ["all", "solo", "2p", "3p", "4p", "a10", "wr30", "wr50", "wr75"] +# Brackets the blob is accumulated per: "all", the exact player-count buckets +# (solo/2p/3p/4p), the A10-gated win-rate ladder, AND their player x skill +# composites (solo:wr50, ...) so the community page can combine co-op size and +# skill at once (like the tier list). Keep the composite keys in sync with +# _COMPOSITE_BRACKETS in run_entity_stats.py. +_PLAYER_BRACKETS = ("solo", "2p", "3p", "4p") +_SKILL_BRACKETS = ("a10", "wr30", "wr50", "wr75") +_BLOB_BRACKETS = ( + ["all"] + + list(_PLAYER_BRACKETS) + + list(_SKILL_BRACKETS) + + [f"{p}:{c}" for p in _PLAYER_BRACKETS for c in _SKILL_BRACKETS] +) def _new_acc_one() -> dict[str, Any]: diff --git a/backend/app/services/run_entity_stats.py b/backend/app/services/run_entity_stats.py index 96fa9688..342c9ec3 100644 --- a/backend/app/services/run_entity_stats.py +++ b/backend/app/services/run_entity_stats.py @@ -221,7 +221,9 @@ def _winrate_brackets(ascension: int, winrate: float | None) -> list[str]: # Score + Win% only). The full-Elo v12 build over 26 brackets x ~740k runs was # too heavy to finish, so the snapshot never advanced past v10; this cuts the # walk back to a healthy duration. The bump forces the (now cheap) rebuild. -SNAPSHOT_VERSION = 13 +# Version 14: the community blob also carries the player x skill composites so +# /community-stats can combine both axes (additive; the bump forces the rebuild). +SNAPSHOT_VERSION = 14 # The oldest snapshot version readers can still serve. Bump SNAPSHOT_VERSION # on every shape change; bump this floor ONLY when a change actually breaks # readers (a removed/retyped field). Everything in between is additive, and @@ -229,8 +231,13 @@ def _winrate_brackets(ascension: int, winrate: float | None) -> list[str]: # rebuild takes beats serving nothing: to users an empty stats page is # indistinguishable from the site losing its data. SNAPSHOT_MIN_COMPAT = 3 -# Leader rebuilds the heavy walk at most this often. -_SNAPSHOT_REBUILD_SECONDS = 10 * 60 +# Leader rebuilds the heavy walk at most this often. The walk reads every run +# blob (~750k files, ~80 min on the current box), so a 10-minute interval had the +# leader walking almost continuously — one worker pegged, a full run + file scan +# back-to-back — which starved the DB and slowed the whole site. These stats +# (tier list / community / Codex Score) don't need minute-fresh data, so rebuild +# a few times a day instead; the walk now runs a fraction of the time. +_SNAPSHOT_REBUILD_SECONDS = 2 * 60 * 60 # Workers reload the snapshot from Mongo this often (cheap read). _SNAPSHOT_LOAD_SECONDS = 5 * 60 @@ -1017,6 +1024,12 @@ def _build_cache_data() -> tuple[dict, dict, dict, dict]: mp_blob_brackets = blob_brackets + [ c for c in extra_brackets if c in ("solo", "2p", "3p", "4p") ] + # Community blob ALSO slices by the player x skill composites (solo:wr50, + # ...) so its page can combine both axes like the tier list. These only + # apply to A10 runs from qualifying players, so most runs add nothing. + community_blob_brackets = mp_blob_brackets + [ + c for c in extra_brackets if c in _COMPOSITE_BRACKETS_SET + ] # Community / fun stats, accumulated from the same blob. Guarded so # one malformed blob can't abort the whole snapshot rebuild. @@ -1024,7 +1037,7 @@ def _build_cache_data() -> tuple[dict, dict, dict, dict]: community_stats.accumulate( community_acc, blob, - brackets=mp_blob_brackets, + brackets=community_blob_brackets, run_hash=run_hash, is_win=is_win, character=character, diff --git a/frontend/app/community-stats/page.tsx b/frontend/app/community-stats/page.tsx index 212ad4c6..e82d1a8f 100644 --- a/frontend/app/community-stats/page.tsx +++ b/frontend/app/community-stats/page.tsx @@ -110,7 +110,7 @@ function Empty({ jsonLd, current }: { jsonLd: object[]; current: string }) {

Community Stats

- +

No data for this bracket yet. Stats build from community-submitted runs, submit a run to seed them.

@@ -161,7 +161,7 @@ export default async function CommunityStatsPage({

{/* Content bracket: slice every dataset below by skill. */} - + {/* Headline numbers */}