diff --git a/CLAUDE.md b/CLAUDE.md index 3395d62..65f99e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,6 +30,8 @@ hs card [-l koKR] # card lookup hs card --search --class CLASS [-l koKR] # filtered search hs card --class CLASS [--cost N] [-l koKR] # browse (blank/no --search = wildcard) hs meta sets|classes|types|rarities [-l koKR] +hs meta archetypes [--game-format standard|wild|twist] [--rank legend|...] [--period last-patch|...] # live archetype winrate/tier (Firestone) +hs meta decks [--rank ...] [--period ...] [--sort wilson|winrate|games] # live decks + deck codes (Firestone) hs skill install [--agent claude,cursor,codex,copilot,opencode] [--project] [--use-npx] # install the hearthstone-deck skill into agent skills dirs ``` @@ -65,6 +67,8 @@ Install the bundled `hearthstone-deck` skill into any agent (works across all CL **Do not duplicate SKILL.md at the repo root.** The single source of truth lives inside the plugin. Root-level docs should link to it, not copy it. +**Keep the skill in sync with the CLI.** When a change adds/removes/renames a command, subcommand, or flag (or changes documented behavior), update the `hearthstone-deck` skill in the SAME change: `plugins/hs-cli/skills/hearthstone-deck/SKILL.md` (command surface + trigger description) and the relevant `recipes/*.md` (deck/card/meta). This is part of the definition-of-done for any CLI-surface change. Internal-only refactors that don't change the documented surface don't require a skill edit. + ## Architecture - `src/index.ts` — citty `runMain`, registers subcommands @@ -77,6 +81,7 @@ Install the bundled `hearthstone-deck` skill into any agent (works across all CL - `src/services/skill-bundle.ts` — `hearthstone-deck` SKILL.md + recipes embedded into the binary via Bun text imports. Single source of truth stays at `plugins/hs-cli/skills/hearthstone-deck/`; a test asserts byte-equality with disk. - `src/services/skill-installer.ts` — pure FS writer for the embedded skill (`writeBundle`, `skillExists`) - `src/services/skill-select.ts` — pure agent-selection resolver (`--agent` validation + non-TTY guard); clack multiselect lives in the command, not here +- `src/services/meta-stats.ts` — Firestone constructed win-rate/deck stats (CDN JSON, 1h cache). Helpers: `stats-math.ts` (Wilson lower bound / margin of error / tier band), `archetype-names.ts` (slug→display name, cached). Data: Firestone (firestoneapp.com), used with permission. Ranked by Wilson lower bound, not raw win rate. - `src/types/index.ts` — `Card`, `Deck`, `DeckCard` types + Korean class name map - `tests/` — bun:test files, one per service. Imports use `.ts` extension (Bun resolves native TS). - `tsconfig.json` — typecheck + IDE config. `noEmit: true`, `allowImportingTsExtensions: true`. No separate build config. diff --git a/README.es.md b/README.es.md index d6287be..824c0bb 100644 --- a/README.es.md +++ b/README.es.md @@ -123,6 +123,19 @@ hs meta rarities # FREE, COMMON, RARE, EPIC, LEGENDARY hs meta types # MINION, SPELL, WEAPON, HERO, ... ``` +### Meta en vivo (tasas de victoria, tiers, códigos de mazo) + +`hs meta archetypes` y `hs meta decks` muestran las tasas de victoria actuales del modo construido, tiers y códigos de mazo. + +```bash +hs meta archetypes --game-format standard --rank legend --period past-7 +hs meta decks --rank top-2000-legend -f json +``` + +Flags: `--game-format standard|wild|twist`, `--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all`, `--period last-patch|past-3|past-7|past-20|current-season`, `--min-games N`, `--sort wilson|winrate|games`, `--limit N`. Las filas están ordenadas por el límite inferior de Wilson (no por la tasa de victoria bruta) y muestran una columna de ±margen de error. + +Datos: **Firestone** (firestoneapp.com), usado con permiso. Los códigos de mazo de `hs meta decks` se pueden pasar mediante pipe a `hs deck `. + ### Salida JSON Agrega `-f json` a cualquier comando: diff --git a/README.ja.md b/README.ja.md index 8170786..d2d63ea 100644 --- a/README.ja.md +++ b/README.ja.md @@ -123,6 +123,19 @@ hs meta rarities # FREE, COMMON, RARE, EPIC, LEGENDARY hs meta types # MINION, SPELL, WEAPON, HERO, ... ``` +### ライブメタ(勝率・ティア・デッキコード) + +`hs meta archetypes` と `hs meta decks` は現在の構築フォーマットの勝率、ティア、デッキコードを表示します。 + +```bash +hs meta archetypes --game-format standard --rank legend --period past-7 +hs meta decks --rank top-2000-legend -f json +``` + +フラグ: `--game-format standard|wild|twist`、`--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all`、`--period last-patch|past-3|past-7|past-20|current-season`、`--min-games N`、`--sort wilson|winrate|games`、`--limit N`。行は Wilson 下限値(生の勝率ではない)で並び替えられ、±誤差の列が表示されます。 + +データ: **Firestone** (firestoneapp.com)、使用許可済み。`hs meta decks` のデッキコードは `hs deck ` にパイプできます。 + ### JSON出力 任意のコマンドに `-f json` を追加: diff --git a/README.ko.md b/README.ko.md index 37807c1..a1b7a29 100644 --- a/README.ko.md +++ b/README.ko.md @@ -123,6 +123,19 @@ hs meta rarities # FREE, COMMON, RARE, EPIC, LEGENDARY hs meta types # MINION, SPELL, WEAPON, HERO, ... ``` +### 라이브 메타 (승률, 티어, 덱 코드) + +`hs meta archetypes`와 `hs meta decks`는 현재 일반 모드 승률, 티어, 덱 코드를 보여줍니다. + +```bash +hs meta archetypes --game-format standard --rank legend --period past-7 +hs meta decks --rank top-2000-legend -f json +``` + +플래그: `--game-format standard|wild|twist`, `--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all`, `--period last-patch|past-3|past-7|past-20|current-season`, `--min-games N`, `--sort wilson|winrate|games`, `--limit N`. 행은 Wilson 하한값(원시 승률 기준 아님)으로 정렬되며 ±오차 범위 열이 포함됩니다. + +데이터: **Firestone** (firestoneapp.com), 허가 후 사용. `hs meta decks`의 덱 코드는 `hs deck `에 파이프로 전달할 수 있습니다. + ### JSON 출력 모든 명령어에 `-f json` 추가: diff --git a/README.md b/README.md index 05fe5b2..20f32a9 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,19 @@ hs meta rarities # FREE, COMMON, RARE, EPIC, LEGENDARY hs meta types # MINION, SPELL, WEAPON, HERO, ... ``` +### Live meta (win rates, tiers, deck codes) + +`hs meta archetypes` and `hs meta decks` show current constructed win rates, tiers, and deck codes. + +```bash +hs meta archetypes --game-format standard --rank legend --period past-7 +hs meta decks --rank top-2000-legend -f json +``` + +Flags: `--game-format standard|wild|twist`, `--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all`, `--period last-patch|past-3|past-7|past-20|current-season`, `--min-games N`, `--sort wilson|winrate|games`, `--limit N`. Rows are ranked by the Wilson lower bound (not raw win rate) and show a ±margin-of-error column. + +Data: **Firestone** (firestoneapp.com), used with permission. Deck codes from `hs meta decks` can be piped into `hs deck `. + ### JSON output Add `-f json` to any command for raw structured data: diff --git a/README.zh.md b/README.zh.md index 4a017bd..ae3cec1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -123,6 +123,19 @@ hs meta rarities # FREE, COMMON, RARE, EPIC, LEGENDARY hs meta types # MINION, SPELL, WEAPON, HERO, ... ``` +### 实时环境(胜率、梯队、套牌代码) + +`hs meta archetypes` 和 `hs meta decks` 显示当前构筑赛制的胜率、梯队和套牌代码。 + +```bash +hs meta archetypes --game-format standard --rank legend --period past-7 +hs meta decks --rank top-2000-legend -f json +``` + +标志: `--game-format standard|wild|twist`、`--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all`、`--period last-patch|past-3|past-7|past-20|current-season`、`--min-games N`、`--sort wilson|winrate|games`、`--limit N`。行按 Wilson 下界排序(而非原始胜率),并显示 ±误差列。 + +数据来源: **Firestone** (firestoneapp.com),已获授权使用。`hs meta decks` 输出的套牌代码可通过管道传入 `hs deck `。 + ### JSON 输出 任何命令添加 `-f json`: diff --git a/plugins/hs-cli/README.md b/plugins/hs-cli/README.md index 7a6b983..a82ef6d 100644 --- a/plugins/hs-cli/README.md +++ b/plugins/hs-cli/README.md @@ -76,11 +76,25 @@ Once installed, just talk to Claude: Or invoke explicitly: `/hs-cli:hearthstone-deck`. +## Live meta (win rates, tiers, deck codes) + +`hs meta archetypes` and `hs meta decks` show current constructed win rates, tiers, and deck codes. + +```bash +hs meta archetypes --game-format standard --rank legend --period past-7 +hs meta decks --rank top-2000-legend -f json +``` + +Flags: `--game-format standard|wild|twist`, `--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all`, `--period last-patch|past-3|past-7|past-20|current-season`, `--min-games N`, `--sort wilson|winrate|games`, `--limit N`. Rows are ranked by the Wilson lower bound (not raw win rate) and show a ±margin-of-error column. + +Data: **Firestone** (firestoneapp.com), used with permission. Deck codes from `hs meta decks` can be piped into `hs deck `. + ## Scope (Phase 1) - ✅ Deck code decoding (offline-capable after first fetch) - ✅ Card lookup + search with class/cost filters - ✅ Game metadata (sets, classes, types, rarities) +- ✅ Live meta stats: archetype win rates, tiers, deck codes (Firestone) - ❌ Match history / win rate (Phase 2 — requires Power.log parsing) - ❌ "My saved decks" (Blizzard API has no Hearthstone profile endpoint — no workaround exists) diff --git a/plugins/hs-cli/skills/hearthstone-deck/SKILL.md b/plugins/hs-cli/skills/hearthstone-deck/SKILL.md index ebb027c..3801254 100644 --- a/plugins/hs-cli/skills/hearthstone-deck/SKILL.md +++ b/plugins/hs-cli/skills/hearthstone-deck/SKILL.md @@ -1,6 +1,6 @@ --- name: hearthstone-deck -description: Use when the user wants anything involving Hearthstone — decode/analyze/compare decks, look up cards by name or id, search cards with filters, or query game metadata (sets, classes, card types, rarities). Triggers on deck-code strings (base64 starting with "AAEC", "AAEB", "AAEA"), Hearthstone in any language or community nickname (HS, Blizzard Hearthstone; 하스스톤, 하스, 돌겜, 하섭; ハースストーン, ハース, ハスス; 炉石传说, 炉石, 炉石酒馆战棋; 爐石戰記, 爐石, 爐戰; Hearth, Pedra do Lar; Хартстоун, Хартса), mode keywords in any language (Standard / Wild / Twist / Arena / Battlegrounds / BG; 정규 / 야생 / 전장 / 투기장 / 선술집난투 / 용병단; 标准 / 狂野 / 酒馆战棋 / 竞技场), mana-curve/dust/win-condition/meta/nerf discussion, single-card lookups, name searches in any language, and class/format/expansion queries. +description: Use when the user wants anything involving Hearthstone — decode/analyze/compare decks, look up cards by name or id, search cards with filters, or query game metadata (sets, classes, card types, rarities). Triggers on deck-code strings (base64 starting with "AAEC", "AAEB", "AAEA"), Hearthstone in any language or community nickname (HS, Blizzard Hearthstone; 하스스톤, 하스, 돌겜, 하섭; ハースストーン, ハース, ハスス; 炉石传说, 炉石, 炉石酒馆战棋; 爐石戰記, 爐石, 爐戰; Hearth, Pedra do Lar; Хартстоун, Хартса), mode keywords in any language (Standard / Wild / Twist / Arena / Battlegrounds / BG; 정규 / 야생 / 전장 / 투기장 / 선술집난투 / 용병단; 标准 / 狂野 / 酒馆战棋 / 竞技场), mana-curve/dust/win-condition/meta/nerf discussion, single-card lookups, name searches in any language, class/format/expansion queries, and current-meta tier-list / win-rate / best-deck questions (tier list, winrate, what's strong/best, 메타/티어/승률/무슨 덱이 좋아, ティア/勝率, 强势/胜率/上分). --- # Hearthstone CLI (decks, cards, metadata) @@ -13,6 +13,8 @@ This skill covers the full surface of the `hs` CLI: - `hs card ` — single-card lookup - `hs card --search [--class CLASS] [--cost N]` — name + filter search - `hs meta sets|classes|types|rarities` — game metadata +- `hs meta archetypes` — live archetype win rates + tiers (current meta, Firestone data) +- `hs meta decks` — live top decks with deck codes (current meta, Firestone data) ## Language and naming triggers diff --git a/plugins/hs-cli/skills/hearthstone-deck/recipes/meta.md b/plugins/hs-cli/skills/hearthstone-deck/recipes/meta.md index d7b218e..911922e 100644 --- a/plugins/hs-cli/skills/hearthstone-deck/recipes/meta.md +++ b/plugins/hs-cli/skills/hearthstone-deck/recipes/meta.md @@ -1,6 +1,6 @@ # Metadata recipes -Workflows that use `hs meta sets|classes|types|rarities` to validate filters, normalize user input, or build enum-driven helpers. +Workflows that use `hs meta sets|classes|types|rarities` to validate filters, normalize user input, or build enum-driven helpers — plus the live-meta types `hs meta archetypes|decks` for current win rates, tiers, and deck codes. ## Contents @@ -9,6 +9,7 @@ Workflows that use `hs meta sets|classes|types|rarities` to validate filters, no - [Dust cheat-sheet from rarities](#dust-cheat-sheet-from-rarities) - [Play-relevant card types only](#play-relevant-card-types-only) - [Latest expansion / set membership of a card](#latest-expansion--set-membership-of-a-card) +- [Live meta: tiers, win rates, deck codes](#live-meta-tiers-win-rates-deck-codes) ## JSON shape reminder @@ -78,3 +79,56 @@ hs card EX1_572 -f json | jq .set # set of a specific card -> "EXPERT1" ``` For "is this set in Standard right now?", point the user to the official Blizzard rotation page — that data is not in the CLI. For per-deck set distribution see `recipes/deck.md` (`Set distribution & rotation risk`). + +## Live meta: tiers, win rates, deck codes + +User intent: "what's the best deck right now?", "현재 메타 티어", "强势卡组", "is X tier 1?" + +`hs meta archetypes` (aggregated win rate per archetype = tier view) and `hs meta decks` (individual decks + deck codes) return live constructed stats from **Firestone (firestoneapp.com), used with permission**. This is the ONLY meta/win-rate source in the CLI — `hs meta sets|classes|types|rarities` are static and have no win rates. + +Flags (both): `--game-format standard|wild|twist` (default standard), `--rank legend|top-2000-legend|competitive|legend-diamond|diamond|platinum|bronze-gold|all` (default legend), `--period last-patch|past-3|past-7|past-20|current-season` (default last-patch), `--min-games N` (default 2000 archetypes / 400 decks), `--sort wilson|winrate|games` (default wilson), `--limit N` (table only). Add `-f json` for piping. + +### Reading the output honestly + +```text +hs meta archetypes -f json +{ + "meta": { "source": "Firestone (firestoneapp.com)", "gameFormat", "rank", "period", "lastUpdated", "dataPoints" }, + "rows": [ { "displayName", "playerClass", "winrate", "wilsonLower", "moe", "tier", "totalGames", "deckcode"? } ] +} +``` + +- **Sort is by `wilsonLower` (Wilson score lower bound), not raw `winrate`** — this prevents a tiny-sample 80% deck from topping a proven 55% one. Report the tier/rank from `wilsonLower`, cite `winrate` as the headline number, and mention `±moe` when a deck is close to another. +- **`winrate`/`wilsonLower`/`moe` are 0–1 fractions** (multiply by 100 for %). `moe` is the ±95% margin of error at p=0.5; large `moe` (small `totalGames`) = treat as preliminary. +- **`tier`** is a heuristic band (S ≥57% · A 54–57 · B 52–54 · C 50–52 · D <50) on `wilsonLower`. +- A `[⚠ low sample]` flag (table) / low `dataPoints` (json) means the whole bracket/period is thin — widen `--period` or drop to a broader `--rank`. +- Always attribute Firestone when surfacing these numbers. + +### Top tier list for the current meta + +```bash +hs meta archetypes --rank legend --period past-7 -f json | jq -r ' + .rows[] | "\(.tier) \((.winrate*100)|round/1)% \(.displayName) (n=\(.totalGames))"' | head -15 +``` + +### Best deck of a class, with a ready-to-import code + +```bash +hs meta decks --rank top-2000-legend -f json \ + | jq -r '[.rows[] | select(.playerClass=="rogue")][0] | .deckcode' +# pipe straight into the decoder for the full card list: +CODE=$(hs meta decks -f json | jq -r '.rows[0].deckcode') +hs deck "$CODE" +``` + +### "Is X strong right now?" + +Match the user's archetype name against `displayName` (case-insensitive substring), then quote `winrate` + `tier` + `totalGames`: + +```bash +hs meta archetypes -f json | jq -r --arg q "rogue" ' + .rows[] | select(.displayName|ascii_downcase|contains($q)) + | "\(.displayName): \((.winrate*100)|round/1)% (tier \(.tier), n=\(.totalGames))"' +``` + +Cache live-meta output within an agent turn (1h server cadence; CLI already caches to `~/.hs-cli/` for 1h). diff --git a/src/commands/meta.ts b/src/commands/meta.ts index 0166204..69e8ec9 100644 --- a/src/commands/meta.ts +++ b/src/commands/meta.ts @@ -1,16 +1,53 @@ import { defineCommand } from "citty"; import { getMetadata } from "../services/card-db.ts"; import { resolveLocale } from "../services/locale.ts"; -import { formatMeta } from "../services/formatter.ts"; -import type { OutputFormat } from "../types/index.ts"; +import { formatMeta, formatMetaStats } from "../services/formatter.ts"; +import { loadArchetypeNames } from "../services/archetype-names.ts"; +import { fetchMeta, rankArchetypes, rankDecks, type RankOptions } from "../services/meta-stats.ts"; +import type { + ArchetypeStat, + DeckStat, + GameFormat, + OutputFormat, + RankBracket, + TimePeriod, +} from "../types/index.ts"; -const VALID_TYPES = ["sets", "classes", "types", "rarities"] as const; -type MetaType = (typeof VALID_TYPES)[number]; +const STATIC_TYPES = ["sets", "classes", "types", "rarities"] as const; +const LIVE_TYPES = ["archetypes", "decks"] as const; +const VALID_TYPES = [...STATIC_TYPES, ...LIVE_TYPES] as const; + +const GAME_FORMATS = ["standard", "wild", "twist"] as const; +const RANKS = [ + "legend", + "top-2000-legend", + "competitive", + "legend-diamond", + "diamond", + "platinum", + "bronze-gold", + "all", +] as const; +const PERIODS = ["last-patch", "past-3", "past-7", "past-20", "current-season"] as const; +const SORTS = ["wilson", "winrate", "games"] as const; + +const fail = (message: string): never => { + process.stderr.write(`${message}\n`); + process.exit(1); +}; + +const oneOf = (value: string, allowed: readonly T[], label: string): T => { + if (!(allowed as readonly string[]).includes(value)) { + fail(`Invalid ${label}: ${value}. Must be one of: ${allowed.join(", ")}`); + } + return value as T; +}; export const metaCommand = defineCommand({ meta: { name: "meta", - description: "Show Hearthstone metadata (sets, classes, types, rarities)", + description: + "Hearthstone metadata (sets/classes/types/rarities) and live meta (archetypes/decks)", }, args: { type: { @@ -27,27 +64,90 @@ export const metaCommand = defineCommand({ locale: { type: "string", alias: "l", - description: - "HearthstoneJSON locale (e.g. enUS, koKR, jaJP). Auto-detected from $LANG when omitted.", + description: "HearthstoneJSON locale (static types only)", + }, + "game-format": { + type: "string", + default: "standard", + description: `HS format (archetypes/decks): ${GAME_FORMATS.join(", ")}`, + }, + rank: { + type: "string", + default: "legend", + description: `Rank bracket (archetypes/decks): ${RANKS.join(", ")}`, + }, + period: { + type: "string", + default: "last-patch", + description: `Time window (archetypes/decks): ${PERIODS.join(", ")}`, + }, + "min-games": { + type: "string", + description: "Minimum games filter (default 400 decks / 2000 archetypes)", + }, + sort: { + type: "string", + default: "wilson", + description: `Sort (archetypes/decks): ${SORTS.join(", ")}`, + }, + limit: { + type: "string", + default: "20", + description: "Max rows in table output", }, }, run: async ({ args }) => { const format = args.format as OutputFormat; - const { type } = args; + const type = args.type as string; - if (!VALID_TYPES.includes(type as MetaType)) { - process.stderr.write(`Invalid type: ${type}. Must be one of: ${VALID_TYPES.join(", ")}\n`); - process.exit(1); + if (!(VALID_TYPES as readonly string[]).includes(type)) { + fail(`Invalid type: ${type}. Must be one of: ${VALID_TYPES.join(", ")}`); } + if ((STATIC_TYPES as readonly string[]).includes(type)) { + try { + const locale = resolveLocale(args.locale); + const values = await getMetadata(type as (typeof STATIC_TYPES)[number], locale); + process.stdout.write(`${formatMeta(type, values, format)}\n`); + } catch (err) { + fail(`Error: ${err instanceof Error ? err.message : String(err)}`); + } + return; + } + + const kind = type as "archetypes" | "decks"; + const gameFormat = oneOf( + args["game-format"] as string, + GAME_FORMATS, + "game-format", + ); + const rank = oneOf(args.rank as string, RANKS, "rank"); + const period = oneOf(args.period as string, PERIODS, "period"); + const sort = oneOf(args.sort as string, SORTS, "sort"); + const defaultMinGames = kind === "decks" ? 400 : 2000; + const minGames = args["min-games"] === undefined ? defaultMinGames : Number(args["min-games"]); + const limit = Number(args.limit); + + if (Number.isNaN(minGames) || minGames < 0) fail("--min-games must be a non-negative number"); + if (Number.isNaN(limit) || limit < 1) fail("--limit must be a positive number"); + + const opts: RankOptions = { sort, minGames, limit: format === "json" ? undefined : limit }; + try { - const locale = resolveLocale(args.locale); - const values = await getMetadata(type as MetaType, locale); - process.stdout.write(`${formatMeta(type, values, format)}\n`); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`Error: ${msg}\n`); - process.exit(1); + const names = await loadArchetypeNames(); + if (kind === "archetypes") { + const result = await fetchMeta("archetypes", gameFormat, rank, period); + const rows = rankArchetypes(result.rows, names, opts); + process.stdout.write(`${formatMetaStats(result, rows, format)}\n`); + } else { + const result = await fetchMeta("decks", gameFormat, rank, period); + const rows = rankDecks(result.rows, names, opts); + process.stdout.write(`${formatMetaStats(result, rows, format)}\n`); + } + } catch (err) { + fail( + `Failed to fetch meta from Firestone: ${err instanceof Error ? err.message : String(err)}`, + ); } }, }); diff --git a/src/services/archetype-names.ts b/src/services/archetype-names.ts new file mode 100644 index 0000000..ad34092 --- /dev/null +++ b/src/services/archetype-names.ts @@ -0,0 +1,53 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const TRANSLATIONS_URL = + "https://raw.githubusercontent.com/Zero-to-Heroes/firestone-translations/master/firestone/enUS.json"; +const CACHE_DIR = join(homedir(), ".hs-cli"); +const CACHE_FILE = join(CACHE_DIR, "firestone-archetypes-enUS.json"); +const TTL_MS = 24 * 60 * 60 * 1000; + +export const displayName = (slug: string, map: Record): string => { + const key = slug.startsWith("std-") ? slug.slice(4) : slug; + return map[key] ?? slug; +}; + +const isFresh = async (): Promise => { + try { + const s = await stat(CACHE_FILE); + return Date.now() - s.mtimeMs < TTL_MS; + } catch { + return false; + } +}; + +// Returns the archetype slug->display-name map, cached 24h. On any network error +// with no cache, returns an empty map (callers fall back to raw slugs). +export const loadArchetypeNames = async (): Promise> => { + try { + if (await isFresh()) { + const raw = await readFile(CACHE_FILE, "utf8"); + return (JSON.parse(raw) as { archetype?: Record }).archetype ?? {}; + } + } catch { + // fall through to fetch + } + try { + const res = await fetch(TRANSLATIONS_URL, { + headers: { "User-Agent": "hs-cli (+https://github.com/say8425/hs-cli)" }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const text = await res.text(); + await mkdir(CACHE_DIR, { recursive: true }); + await writeFile(CACHE_FILE, text); + return (JSON.parse(text) as { archetype?: Record }).archetype ?? {}; + } catch { + try { + const raw = await readFile(CACHE_FILE, "utf8"); + return (JSON.parse(raw) as { archetype?: Record }).archetype ?? {}; + } catch { + return {}; + } + } +}; diff --git a/src/services/formatter.ts b/src/services/formatter.ts index 28b36c3..0924099 100644 --- a/src/services/formatter.ts +++ b/src/services/formatter.ts @@ -1,5 +1,13 @@ -import type { Card, Deck, DeckCard, OutputFormat, SkillOutcome } from "../types/index.js"; -import { getFormatKo, getHeroClassKo } from "./deck-decoder.js"; +import type { + Card, + Deck, + DeckCard, + MetaResult, + OutputFormat, + RankedRow, + SkillOutcome, +} from "../types/index.ts"; +import { getFormatKo, getHeroClassKo } from "./deck-decoder.ts"; const buildManaCurve = (cards: readonly DeckCard[]): Record => { const curve: Record = {}; @@ -117,3 +125,44 @@ export const formatSkillOutcomes = ( ) .join("\n"); }; + +const pct = (v: number): string => `${(v * 100).toFixed(1)}%`; + +// Below this many total games in the bracket/period, even the most-played +// archetypes rarely clear the 2000-game archetype floor with a tight margin of +// error, so the whole view is preliminary. ~10× the archetype min-games default. +const LOW_SAMPLE_DATAPOINTS = 20_000; + +export const formatMetaStats = ( + result: MetaResult, + rows: readonly RankedRow[], + format: OutputFormat, +): string => { + if (format === "json") { + return JSON.stringify( + { + meta: { + source: "Firestone (firestoneapp.com)", + gameFormat: result.gameFormat, + rank: result.rank, + period: result.period, + lastUpdated: result.lastUpdated, + dataPoints: result.dataPoints, + }, + rows, + }, + undefined, + 2, + ); + } + const lowSampleFlag = result.dataPoints < LOW_SAMPLE_DATAPOINTS ? " [⚠ low sample]" : ""; + const header = `Data: Firestone (firestoneapp.com) · ${result.gameFormat}/${result.rank}/${result.period} · updated ${result.lastUpdated} · ${result.dataPoints} games${lowSampleFlag}`; + if (rows.length === 0) { + return `${header}\n(no rows meet the --min-games threshold; lower --min-games or widen --period)`; + } + const lines = rows.map((r) => { + const base = `${r.tier.padEnd(2)} ${r.displayName.padEnd(22)} ${r.playerClass.padEnd(12)} ${pct(r.winrate).padStart(6)} wilson ${pct(r.wilsonLower).padStart(6)} n=${String(r.totalGames).padStart(6)} ±${pct(r.moe)}`; + return r.deckcode ? `${base} ${r.deckcode}` : base; + }); + return [header, ...lines].join("\n"); +}; diff --git a/src/services/meta-stats.ts b/src/services/meta-stats.ts new file mode 100644 index 0000000..041ab63 --- /dev/null +++ b/src/services/meta-stats.ts @@ -0,0 +1,139 @@ +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { displayName } from "./archetype-names.ts"; +import { marginOfError, tierBand, wilsonLower } from "./stats-math.ts"; +import type { + ArchetypeStat, + DeckStat, + GameFormat, + MetaKind, + MetaResult, + RankBracket, + RankedRow, + TimePeriod, +} from "../types/index.ts"; + +const BASE = "https://static.zerotoheroes.com/api/constructed/stats"; +const CACHE_DIR = join(homedir(), ".hs-cli"); +const TTL_MS = 60 * 60 * 1000; + +export const buildMetaUrl = ( + kind: MetaKind, + format: GameFormat, + rank: RankBracket, + period: TimePeriod, +): string => `${BASE}/${kind}/${format}/${rank}/${period}/overview-from-hourly.gz.json`; + +export interface RankOptions { + readonly sort: "wilson" | "winrate" | "games"; + readonly minGames: number; + readonly limit?: number; +} + +const safeWinrate = (winrate: number, wins: number, games: number): number => { + if (Number.isFinite(winrate)) return winrate; + return games > 0 ? wins / games : 0; +}; + +const sortKey = (row: RankedRow, sort: RankOptions["sort"]): number => { + if (sort === "winrate") return row.winrate; + if (sort === "games") return row.totalGames; + return row.wilsonLower; +}; + +const finalize = (rows: readonly RankedRow[], opts: RankOptions): RankedRow[] => { + const filtered = rows.filter((r) => r.totalGames >= opts.minGames); + const sorted = filtered.toSorted((a, b) => sortKey(b, opts.sort) - sortKey(a, opts.sort)); + return opts.limit === undefined ? sorted : sorted.slice(0, opts.limit); +}; + +export const rankArchetypes = ( + stats: readonly ArchetypeStat[], + names: Record, + opts: RankOptions, +): RankedRow[] => { + const rows = stats.map((s): RankedRow => { + const wl = wilsonLower(s.totalWins, s.totalGames); + return { + displayName: displayName(s.name, names), + playerClass: s.heroCardClass, + winrate: safeWinrate(s.winrate, s.totalWins, s.totalGames), + wilsonLower: wl, + moe: marginOfError(s.totalGames), + tier: tierBand(wl * 100), + totalGames: s.totalGames, + }; + }); + return finalize(rows, opts); +}; + +export const rankDecks = ( + stats: readonly DeckStat[], + names: Record, + opts: RankOptions, +): RankedRow[] => { + const rows = stats.map((s): RankedRow => { + const wl = wilsonLower(s.totalWins, s.totalGames); + return { + displayName: displayName(s.archetypeName, names), + playerClass: s.playerClass, + winrate: safeWinrate(s.winrate, s.totalWins, s.totalGames), + wilsonLower: wl, + moe: marginOfError(s.totalGames), + tier: tierBand(wl * 100), + totalGames: s.totalGames, + deckcode: s.decklist, + }; + }); + return finalize(rows, opts); +}; + +const isFresh = async (file: string): Promise => { + try { + return Date.now() - (await stat(file)).mtimeMs < TTL_MS; + } catch { + return false; + } +}; + +export const fetchMeta = async ( + kind: MetaKind, + format: GameFormat, + rank: RankBracket, + period: TimePeriod, +): Promise> => { + const cacheFile = join(CACHE_DIR, `meta-${kind}-${format}-${rank}-${period}.json`); + const parse = (text: string): MetaResult => { + const root = JSON.parse(text) as Record; + const rows = (root[kind === "archetypes" ? "archetypeStats" : "deckStats"] ?? []) as T[]; + if (!Array.isArray(rows)) throw new Error("unexpected Firestone response shape"); + return { + lastUpdated: String(root.lastUpdated ?? ""), + dataPoints: Number(root.dataPoints ?? 0), + gameFormat: format, + rank, + period, + rows, + }; + }; + if (await isFresh(cacheFile)) { + return parse(await readFile(cacheFile, "utf8")); + } + try { + const res = await fetch(buildMetaUrl(kind, format, rank, period), { + headers: { "User-Agent": "hs-cli (+https://github.com/say8425/hs-cli)" }, + }); + if (!res.ok) throw new Error(`Firestone returned HTTP ${res.status}`); + const text = await res.text(); + await mkdir(CACHE_DIR, { recursive: true }); + await writeFile(cacheFile, text); + return parse(text); + } catch (err) { + try { + return parse(await readFile(cacheFile, "utf8")); + } catch { + throw err instanceof Error ? err : new Error(String(err)); + } + } +}; diff --git a/src/services/stats-math.ts b/src/services/stats-math.ts new file mode 100644 index 0000000..96addc8 --- /dev/null +++ b/src/services/stats-math.ts @@ -0,0 +1,25 @@ +// Wilson score interval lower bound — the standard way to rank by a positive rate +// without small samples producing flukes. z=1.96 => 95% confidence. +export const wilsonLower = (wins: number, games: number, z = 1.96): number => { + if (games <= 0) return 0; + const p = wins / games; + const z2 = z * z; + return ( + (p + z2 / (2 * games) - z * Math.sqrt((p * (1 - p) + z2 / (4 * games)) / games)) / + (1 + z2 / games) + ); +}; + +// Margin of error of a proportion at p=0.5 (worst case), 95% confidence. +// Returned as a proportion in 0..1 (e.g. 0.098 = ±9.8%). +export const marginOfError = (games: number): number => (games <= 0 ? 1 : 0.98 / Math.sqrt(games)); + +// Heuristic static tier bands on a winrate PERCENT (apply to the Wilson lower +// bound so small samples cannot inflate a tier). +export const tierBand = (winratePercent: number): string => { + if (winratePercent >= 57) return "S"; + if (winratePercent >= 54) return "A"; + if (winratePercent >= 52) return "B"; + if (winratePercent >= 50) return "C"; + return "D"; +}; diff --git a/src/types/index.ts b/src/types/index.ts index f26dcfe..b24823d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -73,3 +73,59 @@ export const CLASS_NAMES_KO: Record = { DEATHKNIGHT: "죽음의 기사", NEUTRAL: "중립", }; + +export type GameFormat = "standard" | "wild" | "twist"; + +export type RankBracket = + | "legend" + | "top-2000-legend" + | "competitive" + | "legend-diamond" + | "diamond" + | "platinum" + | "bronze-gold" + | "all"; + +export type TimePeriod = "last-patch" | "past-3" | "past-7" | "past-20" | "current-season"; + +export type MetaKind = "archetypes" | "decks"; + +export interface ArchetypeStat { + readonly id: number; + readonly name: string; + readonly heroCardClass: string; + readonly totalGames: number; + readonly totalWins: number; + readonly coreCards: readonly string[]; + readonly winrate: number; +} + +export interface DeckStat { + readonly decklist: string; + readonly archetypeId: number; + readonly archetypeName: string; + readonly playerClass: string; + readonly totalGames: number; + readonly totalWins: number; + readonly winrate: number; +} + +export interface RankedRow { + readonly displayName: string; + readonly playerClass: string; + readonly winrate: number; + readonly wilsonLower: number; + readonly moe: number; + readonly tier: string; + readonly totalGames: number; + readonly deckcode?: string; +} + +export interface MetaResult { + readonly lastUpdated: string; + readonly dataPoints: number; + readonly gameFormat: GameFormat; + readonly rank: RankBracket; + readonly period: TimePeriod; + readonly rows: readonly T[]; +} diff --git a/tests/archetype-names.test.ts b/tests/archetype-names.test.ts new file mode 100644 index 0000000..991d315 --- /dev/null +++ b/tests/archetype-names.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { displayName } from "../src/services/archetype-names.ts"; + +const map = ( + JSON.parse(readFileSync("tests/fixtures/firestone-translations.json", "utf8")) as { + archetype: Record; + } +).archetype; + +describe("archetype-names", () => { + it("maps a known slug to its display name", () => { + expect(displayName("mech-rogue", map)).toBe("Mech Rogue"); + }); + it("strips a leading std- prefix before lookup", () => { + expect(displayName("std-mech-rogue", map)).toBe("Mech Rogue"); + }); + it("falls back to the raw slug when unmapped", () => { + expect(displayName("brand-new-deck", map)).toBe("brand-new-deck"); + }); +}); diff --git a/tests/fixtures/firestone-archetypes.json b/tests/fixtures/firestone-archetypes.json new file mode 100644 index 0000000..a5dbaa8 --- /dev/null +++ b/tests/fixtures/firestone-archetypes.json @@ -0,0 +1,45 @@ +{ + "lastUpdated": "2026-05-29T05:59:51.000Z", + "rankBracket": "legend", + "timePeriod": "past-7", + "format": "standard", + "dataPoints": 568483, + "archetypeStats": [ + { + "id": 1, + "name": "mech-rogue", + "heroCardClass": "rogue", + "totalGames": 5000, + "totalWins": 2750, + "coreCards": [], + "winrate": 0.55 + }, + { + "id": 2, + "name": "pure-paladin", + "heroCardClass": "paladin", + "totalGames": 3000, + "totalWins": 1560, + "coreCards": [], + "winrate": 0.52 + }, + { + "id": 3, + "name": "spike-deck", + "heroCardClass": "mage", + "totalGames": 50, + "totalWins": 40, + "coreCards": [], + "winrate": 0.8 + }, + { + "id": 4, + "name": "druid", + "heroCardClass": "druid", + "totalGames": 2500, + "totalWins": 1200, + "coreCards": [], + "winrate": 0.48 + } + ] +} diff --git a/tests/fixtures/firestone-decks.json b/tests/fixtures/firestone-decks.json new file mode 100644 index 0000000..df20621 --- /dev/null +++ b/tests/fixtures/firestone-decks.json @@ -0,0 +1,27 @@ +{ + "lastUpdated": "2026-05-29T05:59:51.000Z", + "rankBracket": "legend", + "timePeriod": "past-7", + "format": "standard", + "dataPoints": 200000, + "deckStats": [ + { + "decklist": "AAECCODE_A", + "archetypeId": 1, + "archetypeName": "mech-rogue", + "playerClass": "rogue", + "totalGames": 900, + "totalWins": 500, + "winrate": 0.5556 + }, + { + "decklist": "AAECCODE_B", + "archetypeId": 2, + "archetypeName": "pure-paladin", + "playerClass": "paladin", + "totalGames": 120, + "totalWins": 80, + "winrate": 0.6667 + } + ] +} diff --git a/tests/fixtures/firestone-translations.json b/tests/fixtures/firestone-translations.json new file mode 100644 index 0000000..f899c4c --- /dev/null +++ b/tests/fixtures/firestone-translations.json @@ -0,0 +1,8 @@ +{ + "archetype": { + "mech-rogue": "Mech Rogue", + "xl-rainbow-mage": "XL Rainbow Mage", + "pure-paladin": "Pure Paladin", + "no-archetype": "No Archetype" + } +} diff --git a/tests/meta-stats.test.ts b/tests/meta-stats.test.ts new file mode 100644 index 0000000..d1542ba --- /dev/null +++ b/tests/meta-stats.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { buildMetaUrl, rankArchetypes, rankDecks } from "../src/services/meta-stats.ts"; +import type { ArchetypeStat, DeckStat, MetaResult, RankedRow } from "../src/types/index.ts"; +import { formatMetaStats } from "../src/services/formatter.ts"; + +const NAMES = { "mech-rogue": "Mech Rogue", "pure-paladin": "Pure Paladin" }; + +const archFixture = JSON.parse(readFileSync("tests/fixtures/firestone-archetypes.json", "utf8")) + .archetypeStats as ArchetypeStat[]; +const deckFixture = JSON.parse(readFileSync("tests/fixtures/firestone-decks.json", "utf8")) + .deckStats as DeckStat[]; + +describe("meta-stats buildMetaUrl", () => { + it("builds the archetypes endpoint URL", () => { + expect(buildMetaUrl("archetypes", "standard", "legend", "past-7")).toBe( + "https://static.zerotoheroes.com/api/constructed/stats/archetypes/standard/legend/past-7/overview-from-hourly.gz.json", + ); + }); + it("builds the decks endpoint URL", () => { + expect(buildMetaUrl("decks", "wild", "top-2000-legend", "last-patch")).toBe( + "https://static.zerotoheroes.com/api/constructed/stats/decks/wild/top-2000-legend/last-patch/overview-from-hourly.gz.json", + ); + }); +}); + +describe("rankArchetypes", () => { + it("filters by min-games (drops the 50-game spike) and sorts by wilson", () => { + const rows = rankArchetypes(archFixture, NAMES, { sort: "wilson", minGames: 2000 }); + expect(rows.find((r) => r.displayName === "spike-deck")).toBeUndefined(); + expect(rows[0].displayName).toBe("Mech Rogue"); + expect(rows[0].tier).toBeDefined(); + expect(rows[0].wilsonLower).toBeLessThan(rows[0].winrate); + }); + it("respects limit", () => { + const rows = rankArchetypes(archFixture, NAMES, { sort: "winrate", minGames: 1, limit: 1 }); + expect(rows.length).toBe(1); + }); + it("sort=winrate orders by raw winrate (spike-deck top when not filtered)", () => { + const rows = rankArchetypes(archFixture, NAMES, { sort: "winrate", minGames: 1 }); + expect(rows[0].displayName).toBe("spike-deck"); + }); +}); + +describe("rankDecks", () => { + it("filters by min-games and carries the deck code", () => { + const rows = rankDecks(deckFixture, NAMES, { sort: "wilson", minGames: 400 }); + expect(rows.length).toBe(1); + expect(rows[0].deckcode).toBe("AAECCODE_A"); + expect(rows[0].displayName).toBe("Mech Rogue"); + }); +}); + +const sampleResult: MetaResult = { + lastUpdated: "2026-05-29T05:59:51.000Z", + dataPoints: 568483, + gameFormat: "standard", + rank: "legend", + period: "past-7", + rows: [], +}; +const sampleRows: RankedRow[] = [ + { + displayName: "Mech Rogue", + playerClass: "rogue", + winrate: 0.55, + wilsonLower: 0.541, + moe: 0.014, + tier: "A", + totalGames: 5000, + deckcode: "AAECCODE_A", + }, +]; + +describe("formatMetaStats", () => { + it("json output round-trips the rows with meta block", () => { + const out = formatMetaStats(sampleResult, sampleRows, "json"); + const parsed = JSON.parse(out); + expect(parsed.meta.source).toContain("Firestone"); + expect(parsed.meta.dataPoints).toBe(568483); + expect(parsed.rows[0].displayName).toBe("Mech Rogue"); + }); + it("table output contains header attribution and row data", () => { + const out = formatMetaStats(sampleResult, sampleRows, "table"); + expect(out).toContain("Firestone"); + expect(out).toContain("Mech Rogue"); + expect(out).toContain("AAECCODE_A"); + expect(out).toContain("A"); + }); + it("table output flags low sample for a thin bracket/period", () => { + const lowResult = { ...sampleResult, dataPoints: 15000 }; + expect(formatMetaStats(lowResult, sampleRows, "table")).toContain("low sample"); + }); + + it("table output does NOT flag low sample when the dataset is ample", () => { + const ampleResult = { ...sampleResult, dataPoints: 600000 }; + expect(formatMetaStats(ampleResult, sampleRows, "table")).not.toContain("low sample"); + }); + + it("low-sample boundary: exactly 20000 dataPoints is not flagged", () => { + const boundaryResult = { ...sampleResult, dataPoints: 20000 }; + expect(formatMetaStats(boundaryResult, sampleRows, "table")).not.toContain("low sample"); + }); + + it("json output is unaffected by empty rows (returns rows: [])", () => { + const parsed = JSON.parse(formatMetaStats(sampleResult, [], "json")); + expect(parsed.rows).toEqual([]); + expect(parsed.meta.source).toContain("Firestone"); + }); + + it("table output shows a no-rows message when nothing meets the threshold", () => { + const out = formatMetaStats(sampleResult, [], "table"); + expect(out).toContain("Firestone"); + expect(out).toContain("--min-games"); + }); +}); diff --git a/tests/stats-math.test.ts b/tests/stats-math.test.ts new file mode 100644 index 0000000..5a09ae1 --- /dev/null +++ b/tests/stats-math.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "bun:test"; +import { marginOfError, tierBand, wilsonLower } from "../src/services/stats-math.ts"; + +describe("stats-math", () => { + it("wilsonLower penalises small samples below high raw winrate", () => { + const small = wilsonLower(95, 100); + const large = wilsonLower(950, 1000); + expect(large).toBeGreaterThan(small); + expect(wilsonLower(3, 3)).toBeLessThan(large); + }); + + it("wilsonLower returns 0 for zero games", () => { + expect(wilsonLower(0, 0)).toBe(0); + }); + + it("wilsonLower is between 0 and the raw rate", () => { + const w = wilsonLower(550, 1000); + expect(w).toBeGreaterThan(0); + expect(w).toBeLessThan(0.55); + }); + + it("marginOfError matches 0.98/sqrt(n)", () => { + expect(marginOfError(100)).toBeCloseTo(0.098, 3); + expect(marginOfError(1000)).toBeCloseTo(0.031, 3); + expect(marginOfError(0)).toBe(1); + }); + + it("tierBand maps winrate percent to S/A/B/C/D", () => { + expect(tierBand(58)).toBe("S"); + expect(tierBand(57)).toBe("S"); + expect(tierBand(55)).toBe("A"); + expect(tierBand(53)).toBe("B"); + expect(tierBand(51)).toBe("C"); + expect(tierBand(49)).toBe("D"); + }); +});