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
16 changes: 16 additions & 0 deletions backend/app/routers/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
130 changes: 130 additions & 0 deletions backend/app/services/runs_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import json
import os
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path

Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions contributing/API_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions frontend/app/components/EntityRunStats.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 (
<div className="rounded border border-[var(--border-subtle)] bg-[var(--bg-primary)]/40 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-[var(--text-muted)] mb-0.5">
{label}
</div>
<div className="text-lg font-semibold text-[var(--text-primary)] tabular-nums">
{value}
</div>
{sub && (
<div className="text-[10px] text-[var(--text-muted)] mt-0.5 tabular-nums">
{sub}
</div>
)}
</div>
);
}

function characterPretty(c: string): string {
// Names from the runs DB are uppercase enum values (IRONCLAD,
// NECROBINDER). Title-case for display.
Expand All @@ -71,9 +128,18 @@ function characterPretty(c: string): string {
*/
export default function EntityRunStats({ entityType, entityId, entityName }: Props) {
const [stats, setStats] = useState<EntityStats | null>(null);
const [cardStats, setCardStats] = useState<CardStats | null>(null);

useEffect(() => {
cachedFetch<EntityStats>(`${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<CardStats>(`${API}/api/runs/card-stats/${entityId}`).then(setCardStats);
}
}, [entityType, entityId]);

if (!stats) {
Expand Down Expand Up @@ -158,6 +224,89 @@ export default function EntityRunStats({ entityType, entityId, entityName }: Pro
)}
</p>

{/* 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 && (
<div className="space-y-3">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
Card stats
</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{cardStats.pick_rate != null && (
<StatTile
label="Pick rate when offered"
value={`${Math.round(cardStats.pick_rate * 100)}%`}
sub={`${cardStats.n_picked.toLocaleString()} / ${cardStats.n_offered.toLocaleString()}`}
/>
)}
{cardStats.skip_rate != null && (
<StatTile
label="Skip rate"
value={`${Math.round(cardStats.skip_rate * 100)}%`}
/>
)}
{cardStats.win_rate_when_in_deck != null && (
<StatTile
label="Win rate when in deck"
value={`${Math.round(cardStats.win_rate_when_in_deck * 100)}%`}
sub={`${cardStats.n_wins_with_card.toLocaleString()} / ${cardStats.n_runs_with_card.toLocaleString()} runs`}
/>
)}
{cardStats.avg_copies_winning != null && (
<StatTile
label="Avg copies (winning)"
value={cardStats.avg_copies_winning.toFixed(2)}
sub={
cardStats.avg_copies_all != null
? `vs ${cardStats.avg_copies_all.toFixed(2)} overall`
: undefined
}
/>
)}
{cardStats.upgrade_rate != null && (
<StatTile
label="Upgrade rate"
value={`${Math.round(cardStats.upgrade_rate * 100)}%`}
/>
)}
{cardStats.avg_ascension_picked != null && (
<StatTile
label="Avg ascension picked at"
value={cardStats.avg_ascension_picked.toFixed(1)}
/>
)}
</div>

{cardStats.top_synergies.length > 0 && (
<div>
<h4 className="text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)] mb-2 mt-4">
Most paired with (winning decks)
</h4>
<ul className="space-y-1 text-sm">
{cardStats.top_synergies.map((s) => (
<li
key={s.card_id}
className="flex items-center justify-between border-b border-[var(--border-subtle)] last:border-b-0 py-1.5"
>
<Link
href={`/cards/${s.card_id.toLowerCase()}`}
className="text-[var(--text-secondary)] hover:text-[var(--accent-gold)] hover:underline"
>
{prettyId(s.card_id)}
</Link>
<span className="text-xs text-[var(--text-muted)] font-mono tabular-nums">
{s.co_runs.toLocaleString()} winning runs
</span>
</li>
))}
</ul>
</div>
)}
</div>
)}

{/* Per-character breakdown table — hidden when empty. */}
{!empty && stats.by_character.length > 0 && (
<div>
Expand Down
Loading