From 2507327b05f58d59ced0d0cbfbec76251524b3de Mon Sep 17 00:00:00 2001
From: Peter Lord
Date: Tue, 12 May 2026 01:20:00 -0700
Subject: [PATCH] =?UTF-8?q?Add=20expanded=20card=20stats=20=E2=80=94=20/ap?=
=?UTF-8?q?i/runs/card-stats/{card=5Fid}=20+=20Stats=20tab=20block?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Roadmap #2 (after Codex Score). Adds richer per-card community-meta stats
to the card detail page Stats tab beyond the bulk Codex Score feed.
New endpoint GET /api/runs/card-stats/{card_id} backed by
get_card_stats() in services/runs_db.py. Aggregates from existing
run_cards + run_card_choices tables — no schema migration.
Returned fields:
pick_rate / skip_rate from run_card_choices
win_rate_when_in_deck runs containing card / total of those
avg_copies_winning / avg_copies_all deck composition signal
upgrade_rate % of times upgraded when present
avg_ascension_picked skews up for late-game darlings
top_synergies (top 5) self-join on run_cards in wins
Computed on-demand. Each detail-page hit is one bounded SELECT + one
synergy self-join (rows for one card_id ~ low thousands, fans out to
~30 deck slots each). Well under the score-walker pre-warm budget.
Rate-limited at 120/min to match the existing /stats endpoints.
EntityRunStats now fetches /api/runs/card-stats/{id} additionally when
entityType === "cards" and renders a six-tile grid + top-5 synergy list
below the prose summary. Stat tile component lives in the same file
since it's not used elsewhere.
prettyId helper title-cases UPPER_SNAKE ids cheaply (no name-lookup
round-trip) — fine since synergy cards are clickable to their detail
pages where the canonical name lives.
contributing/API_REFERENCE.md gets a new row documenting the endpoint
and the field set.
---
backend/app/routers/runs.py | 16 +++
backend/app/services/runs_db.py | 130 ++++++++++++++++++
contributing/API_REFERENCE.md | 1 +
frontend/app/components/EntityRunStats.tsx | 149 +++++++++++++++++++++
4 files changed, 296 insertions(+)
diff --git a/backend/app/routers/runs.py b/backend/app/routers/runs.py
index 863bf068..39c1ea7e 100644
--- a/backend/app/routers/runs.py
+++ b/backend/app/routers/runs.py
@@ -476,6 +476,22 @@ def get_entity_scores(request: Request, entity_type: str):
_stats_cache: dict[tuple, tuple[float, dict]] = {}
+@router.get("/card-stats/{card_id}", tags=["Runs"])
+@limiter.limit("120/minute")
+def get_card_run_stats(request: Request, card_id: str):
+ """Detailed per-card community stats for the card detail-page Stats tab.
+
+ Richer than the bulk Codex Score feed — adds win rate when in deck,
+ pick/skip rate, avg copies in winning decks, upgrade rate, avg
+ ascension picked at, and the top 5 synergy cards. 5-min TTL cache
+ in services/runs_db.py so the synergy self-join doesn't repeat per
+ pageview.
+ """
+ from ..services.runs_db import get_card_stats
+
+ return get_card_stats(card_id.upper())
+
+
@router.get("/stats", tags=["Runs"])
@limiter.limit("120/minute")
def get_community_stats(
diff --git a/backend/app/services/runs_db.py b/backend/app/services/runs_db.py
index 2d173a4f..0721ce8b 100644
--- a/backend/app/services/runs_db.py
+++ b/backend/app/services/runs_db.py
@@ -13,6 +13,7 @@
import json
import os
import sqlite3
+import time
from contextlib import contextmanager
from pathlib import Path
@@ -799,5 +800,134 @@ def get_stats(
}
+# Per-card-stats cache. The self-join in get_card_stats() (run_cards × run_cards
+# for synergy detection) is the heaviest query in the codebase, and with 576
+# cards each backed by a detail page, browse traffic can easily fire one call
+# per page view. 5-minute TTL collapses bursts to a single rebuild without
+# making any individual card's stats noticeably stale (community win rates
+# move on the order of hours, not minutes).
+_CARD_STATS_TTL_SECONDS = 300
+_card_stats_cache: dict[str, tuple[float, dict]] = {}
+
+
+def get_card_stats(card_id: str) -> dict:
+ """Detailed per-card stats from community runs.
+
+ Powers the Stats tab on card detail pages — richer than the bulk
+ Codex Score feed since each card detail page only loads one. SQL
+ aggregations run on-demand (no precompute) because each detail page
+ hit is bounded by browse traffic, the queries are well-indexed, and
+ the answer changes as runs accumulate.
+
+ Cached in-memory per card_id (5 min TTL). The self-join for synergies
+ is otherwise expensive enough that a popular card page could dominate
+ backend latency. Cache eviction happens inline on access.
+ """
+ now = time.monotonic()
+ hit = _card_stats_cache.get(card_id)
+ if hit and now - hit[0] < _CARD_STATS_TTL_SECONDS:
+ return hit[1]
+ # GC expired entries on the fly so the dict doesn't grow unbounded.
+ for k in [
+ k
+ for k, (t, _) in _card_stats_cache.items()
+ if now - t >= _CARD_STATS_TTL_SECONDS
+ ]:
+ del _card_stats_cache[k]
+ result = _get_card_stats_uncached(card_id)
+ _card_stats_cache[card_id] = (now, result)
+ return result
+
+
+def _get_card_stats_uncached(card_id: str) -> dict:
+ """Original synchronous body of get_card_stats. Separated so the cached
+ wrapper can stay readable.
+
+ Returns:
+ n_runs_with_card / n_wins_with_card / win_rate_when_in_deck
+ n_offered / n_picked / pick_rate / skip_rate
+ avg_copies_winning / avg_copies_all
+ upgrade_rate
+ avg_ascension_picked
+ top_synergies: [{card_id, co_runs}] -- cards most often co-present
+ in winning decks with this one
+ """
+ with get_conn() as conn:
+ agg = conn.execute(
+ """
+ SELECT
+ (SELECT COUNT(DISTINCT rc.run_id) FROM run_cards rc
+ WHERE rc.card_id = ?) AS n_runs_with_card,
+ (SELECT COUNT(DISTINCT rc.run_id) FROM run_cards rc
+ JOIN runs r ON rc.run_id = r.id
+ WHERE rc.card_id = ? AND r.win = 1) AS n_wins_with_card,
+ (SELECT COUNT(*) FROM run_card_choices
+ WHERE card_id = ?) AS n_offered,
+ (SELECT COALESCE(SUM(was_picked), 0) FROM run_card_choices
+ WHERE card_id = ?) AS n_picked,
+ (SELECT 1.0 * SUM(upgraded) / COUNT(*) FROM run_cards
+ WHERE card_id = ?) AS upgrade_rate,
+ (SELECT 1.0 * COUNT(*) / NULLIF(COUNT(DISTINCT rc.run_id), 0)
+ FROM run_cards rc JOIN runs r ON rc.run_id = r.id
+ WHERE rc.card_id = ? AND r.win = 1) AS avg_copies_winning,
+ (SELECT 1.0 * COUNT(*) / NULLIF(COUNT(DISTINCT run_id), 0)
+ FROM run_cards WHERE card_id = ?) AS avg_copies_all,
+ (SELECT 1.0 * SUM(r.ascension) / NULLIF(COUNT(*), 0)
+ FROM run_card_choices rc JOIN runs r ON rc.run_id = r.id
+ WHERE rc.card_id = ? AND rc.was_picked = 1) AS avg_ascension_picked
+ """,
+ [card_id] * 8,
+ ).fetchone()
+
+ # Synergy: cards most often co-present in winning decks with this one.
+ # Self-join on run_cards is bounded by the rows for `card_id` (typically
+ # a few thousand), then fans out to that run's other cards (~30 each).
+ synergies = conn.execute(
+ """
+ SELECT rc2.card_id AS card_id, COUNT(DISTINCT rc1.run_id) AS co_runs
+ FROM run_cards rc1
+ JOIN run_cards rc2
+ ON rc1.run_id = rc2.run_id AND rc1.card_id != rc2.card_id
+ JOIN runs r ON rc1.run_id = r.id
+ WHERE rc1.card_id = ? AND r.win = 1
+ GROUP BY rc2.card_id
+ ORDER BY co_runs DESC
+ LIMIT 5
+ """,
+ [card_id],
+ ).fetchall()
+
+ n_runs = agg["n_runs_with_card"] or 0
+ n_wins = agg["n_wins_with_card"] or 0
+ n_offered = agg["n_offered"] or 0
+ n_picked = agg["n_picked"] or 0
+
+ return {
+ "card_id": card_id,
+ "n_runs_with_card": n_runs,
+ "n_wins_with_card": n_wins,
+ "win_rate_when_in_deck": round(n_wins / n_runs, 4) if n_runs else None,
+ "n_offered": n_offered,
+ "n_picked": n_picked,
+ "pick_rate": round(n_picked / n_offered, 4) if n_offered else None,
+ "skip_rate": round(1 - n_picked / n_offered, 4) if n_offered else None,
+ "avg_copies_winning": round(agg["avg_copies_winning"], 2)
+ if agg["avg_copies_winning"]
+ else None,
+ "avg_copies_all": round(agg["avg_copies_all"], 2)
+ if agg["avg_copies_all"]
+ else None,
+ "upgrade_rate": round(agg["upgrade_rate"], 4)
+ if agg["upgrade_rate"] is not None
+ else None,
+ "avg_ascension_picked": round(agg["avg_ascension_picked"], 2)
+ if agg["avg_ascension_picked"] is not None
+ else None,
+ "top_synergies": [
+ {"card_id": s["card_id"], "co_runs": s["co_runs"]} for s in synergies
+ ],
+ }
+
+
# Initialize on import
init_db()
diff --git a/contributing/API_REFERENCE.md b/contributing/API_REFERENCE.md
index a594f3cf..2bdd7ec4 100644
--- a/contributing/API_REFERENCE.md
+++ b/contributing/API_REFERENCE.md
@@ -66,6 +66,7 @@ All data endpoints accept `?lang=` (default: `eng`). Rate limited to 60 req/min
| `GET /api/runs/shared/{hash}` | GET | Retrieve a shared run by hash. Response merges `username` from `runs.db` so the shared-run page can render "by {username}" without a second round trip. |
| `GET /api/runs/leaderboard` | GET | Ranked wins-only leaderboard. Filters: `category` (`fastest`, `highest_ascension`), `character`, `page`, `limit` |
| `GET /api/runs/scores/{type}` | GET | Codex Score per entity. `type` ∈ `cards` / `relics` / `potions`. Returns `{ id, score (0–100), tier (S/A/B/C/D/F), wins, losses, n }[]`. Bayesian-shrunk win rate; pre-warmed on FastAPI startup. See `services/run_entity_stats.py` and `/leaderboards/scoring` for the formula. |
+| `GET /api/runs/card-stats/{card_id}` | GET | Detailed per-card community stats — powers the card detail-page Stats tab. Returns `pick_rate`, `skip_rate`, `win_rate_when_in_deck`, `avg_copies_winning` / `_all`, `upgrade_rate`, `avg_ascension_picked`, plus `top_synergies` (top 5 cards most often co-present in winning decks). Computed on-demand from `run_cards` + `run_card_choices`. |
| `GET /api/runs/versions` | GET | Distinct `build_id` values across submitted runs — powers the version filter dropdown |
## Utility
diff --git a/frontend/app/components/EntityRunStats.tsx b/frontend/app/components/EntityRunStats.tsx
index db1dc743..e1376e90 100644
--- a/frontend/app/components/EntityRunStats.tsx
+++ b/frontend/app/components/EntityRunStats.tsx
@@ -29,6 +29,37 @@ interface EntityStats {
last_run_hash: string | null;
}
+interface SynergyRow {
+ card_id: string;
+ co_runs: number;
+}
+
+interface CardStats {
+ card_id: string;
+ n_runs_with_card: number;
+ n_wins_with_card: number;
+ win_rate_when_in_deck: number | null;
+ n_offered: number;
+ n_picked: number;
+ pick_rate: number | null;
+ skip_rate: number | null;
+ avg_copies_winning: number | null;
+ avg_copies_all: number | null;
+ upgrade_rate: number | null;
+ avg_ascension_picked: number | null;
+ top_synergies: SynergyRow[];
+}
+
+function prettyId(id: string): string {
+ // CARD_ID like "STRIKE_IRONCLAD" → "Strike Ironclad". Cheap title-case
+ // without a name-lookup round-trip.
+ return id
+ .toLowerCase()
+ .split("_")
+ .map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w))
+ .join(" ");
+}
+
interface Props {
entityType: "relics" | "cards" | "potions";
entityId: string;
@@ -55,6 +86,32 @@ function relativeTime(iso: string | null): string {
return `${Math.floor(months / 12)}y ago`;
}
+function StatTile({
+ label,
+ value,
+ sub,
+}: {
+ label: string;
+ value: string;
+ sub?: string;
+}) {
+ return (
+
+
+ {label}
+
+
+ {value}
+
+ {sub && (
+
+ {sub}
+
+ )}
+
+ );
+}
+
function characterPretty(c: string): string {
// Names from the runs DB are uppercase enum values (IRONCLAD,
// NECROBINDER). Title-case for display.
@@ -71,9 +128,18 @@ function characterPretty(c: string): string {
*/
export default function EntityRunStats({ entityType, entityId, entityName }: Props) {
const [stats, setStats] = useState(null);
+ const [cardStats, setCardStats] = useState(null);
useEffect(() => {
cachedFetch(`${API}/api/runs/stats/${entityType}/${entityId}`).then(setStats);
+ // Card detail pages get an additional richer aggregate (pick/skip, copies,
+ // synergies). Relics/potions have less interesting per-entity data so we
+ // skip the round-trip for them. Non-card entityType doesn't reset
+ // cardStats — the render block is gated on entityType === "cards" anyway,
+ // and the conditional setState would trip react-hooks/set-state-in-effect.
+ if (entityType === "cards") {
+ cachedFetch(`${API}/api/runs/card-stats/${entityId}`).then(setCardStats);
+ }
}, [entityType, entityId]);
if (!stats) {
@@ -158,6 +224,89 @@ export default function EntityRunStats({ entityType, entityId, entityName }: Pro
)}
+ {/* Card-specific aggregates: pick/skip rates, copies in winning decks,
+ upgrade rate, ascension trend, and top synergy cards from winning
+ decks. Only rendered for entityType==="cards" and only when we have
+ enough samples to be useful. */}
+ {entityType === "cards" && cardStats && cardStats.n_runs_with_card > 0 && (
+