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 && ( +
+

+ Card stats +

+
+ {cardStats.pick_rate != null && ( + + )} + {cardStats.skip_rate != null && ( + + )} + {cardStats.win_rate_when_in_deck != null && ( + + )} + {cardStats.avg_copies_winning != null && ( + + )} + {cardStats.upgrade_rate != null && ( + + )} + {cardStats.avg_ascension_picked != null && ( + + )} +
+ + {cardStats.top_synergies.length > 0 && ( +
+

+ Most paired with (winning decks) +

+
    + {cardStats.top_synergies.map((s) => ( +
  • + + {prettyId(s.card_id)} + + + {s.co_runs.toLocaleString()} winning runs + +
  • + ))} +
+
+ )} +
+ )} + {/* Per-character breakdown table — hidden when empty. */} {!empty && stats.by_character.length > 0 && (