Skip to content
Open
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
19 changes: 9 additions & 10 deletions backend/app/routers/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 13 additions & 4 deletions backend/app/services/community_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
21 changes: 17 additions & 4 deletions backend/app/services/run_entity_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,16 +221,23 @@ 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
# serving a slightly-stale snapshot for the ~15 minutes a version-bump
# 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

Expand Down Expand Up @@ -1017,14 +1024,20 @@ 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.
try:
community_stats.accumulate(
community_acc,
blob,
brackets=mp_blob_brackets,
brackets=community_blob_brackets,
run_hash=run_hash,
is_win=is_win,
character=character,
Expand Down
4 changes: 2 additions & 2 deletions frontend/app/community-stats/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ function Empty({ jsonLd, current }: { jsonLd: object[]; current: string }) {
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<JsonLd data={jsonLd} />
<h1 className="text-3xl font-bold mb-2"><span className="text-[var(--accent-gold)]">Community Stats</span></h1>
<BracketFilter basePath="/community-stats" current={current} />
<BracketFilter basePath="/community-stats" current={current} composite />
<p className="text-sm text-[var(--text-muted)]">
No data for this bracket yet. Stats build from community-submitted runs, <Link href="/leaderboards/submit" className="text-[var(--accent-gold)] hover:underline">submit a run</Link> to seed them.
</p>
Expand Down Expand Up @@ -161,7 +161,7 @@ export default async function CommunityStatsPage({
</p>

{/* Content bracket: slice every dataset below by skill. */}
<BracketFilter basePath="/community-stats" current={bracket} />
<BracketFilter basePath="/community-stats" current={bracket} composite />

{/* Headline numbers */}
<section className="mb-10">
Expand Down
Loading