From 1b888b55d80ad7ed95350dadf464a64dbdf87ac5 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:13:52 +0900 Subject: [PATCH 01/16] feat: add meta-stats types (GameFormat, ArchetypeStat, DeckStat, RankedRow) --- src/types/index.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) 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[]; +} From 894b3ee28a42ca204e26ae3fb1d7d08a1b6c76c0 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:15:41 +0900 Subject: [PATCH 02/16] feat: add stats-math (wilson lower bound, margin of error, tier band) --- src/services/stats-math.ts | 26 ++++++++++++++++++++++++++ tests/stats-math.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/services/stats-math.ts create mode 100644 tests/stats-math.test.ts diff --git a/src/services/stats-math.ts b/src/services/stats-math.ts new file mode 100644 index 0000000..dd6a1d9 --- /dev/null +++ b/src/services/stats-math.ts @@ -0,0 +1,26 @@ +// 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/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"); + }); +}); From fccb2f79b6aebc3afc0f288529df512d80a0de0d Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:17:52 +0900 Subject: [PATCH 03/16] feat: add archetype-names (slug->display, Other detection, cached translations) --- src/services/archetype-names.ts | 73 ++++++++++++++++++++++ tests/archetype-names.test.ts | 30 +++++++++ tests/fixtures/firestone-translations.json | 8 +++ 3 files changed, 111 insertions(+) create mode 100644 src/services/archetype-names.ts create mode 100644 tests/archetype-names.test.ts create mode 100644 tests/fixtures/firestone-translations.json diff --git a/src/services/archetype-names.ts b/src/services/archetype-names.ts new file mode 100644 index 0000000..6dd8c2c --- /dev/null +++ b/src/services/archetype-names.ts @@ -0,0 +1,73 @@ +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 CLASS_SLUGS: readonly string[] = [ + "deathknight", + "demonhunter", + "druid", + "hunter", + "mage", + "paladin", + "priest", + "rogue", + "shaman", + "warlock", + "warrior", +]; + +export const displayName = (slug: string, map: Record): string => { + const key = slug.startsWith("std-") ? slug.slice(4) : slug; + return map[key] ?? slug; +}; + +// A bare class-name slug (optionally with "xl"/dashes) is Firestone's catch-all bucket. +export const isOtherBucket = (slug: string): boolean => { + const norm = slug.toLowerCase().replaceAll("xl", "").replaceAll("-", "").trim(); + return CLASS_SLUGS.includes(norm); +}; + +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/tests/archetype-names.test.ts b/tests/archetype-names.test.ts new file mode 100644 index 0000000..fd6a0ff --- /dev/null +++ b/tests/archetype-names.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { CLASS_SLUGS, displayName, isOtherBucket } 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"); + }); + it("detects catch-all class-name buckets", () => { + expect(isOtherBucket("druid")).toBe(true); + expect(isOtherBucket("xl-druid")).toBe(true); + expect(isOtherBucket("mech-rogue")).toBe(false); + }); + it("exposes the 11 class slugs", () => { + expect(CLASS_SLUGS.includes("deathknight")).toBe(true); + expect(CLASS_SLUGS.length).toBe(11); + }); +}); 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" + } +} From 6955fc00d0dcf9f093cb8984daaba839e08f7a72 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:20:48 +0900 Subject: [PATCH 04/16] feat: add meta-stats (Firestone fetch+cache, pure rank transforms) --- src/services/meta-stats.ts | 134 +++++++++++++++++++++++ tests/fixtures/firestone-archetypes.json | 13 +++ tests/fixtures/firestone-decks.json | 11 ++ tests/meta-stats.test.ts | 53 +++++++++ 4 files changed, 211 insertions(+) create mode 100644 src/services/meta-stats.ts create mode 100644 tests/fixtures/firestone-archetypes.json create mode 100644 tests/fixtures/firestone-decks.json create mode 100644 tests/meta-stats.test.ts diff --git a/src/services/meta-stats.ts b/src/services/meta-stats.ts new file mode 100644 index 0000000..f0d2d06 --- /dev/null +++ b/src/services/meta-stats.ts @@ -0,0 +1,134 @@ +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 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: s.winrate, + 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: s.winrate, + 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/tests/fixtures/firestone-archetypes.json b/tests/fixtures/firestone-archetypes.json new file mode 100644 index 0000000..f42a5f3 --- /dev/null +++ b/tests/fixtures/firestone-archetypes.json @@ -0,0 +1,13 @@ +{ + "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.80 }, + { "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..c5b490b --- /dev/null +++ b/tests/fixtures/firestone-decks.json @@ -0,0 +1,11 @@ +{ + "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/meta-stats.test.ts b/tests/meta-stats.test.ts new file mode 100644 index 0000000..2701e9a --- /dev/null +++ b/tests/meta-stats.test.ts @@ -0,0 +1,53 @@ +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 } from "../src/types/index.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"); + }); +}); From e6e66980fcfec0bc0847b4c1c59ff5d4bbadf487 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:23:29 +0900 Subject: [PATCH 05/16] feat: add formatMetaStats (table/json) for ranked meta rows --- src/services/formatter.ts | 35 ++++++++++++++++++++++++++++++++++- tests/meta-stats.test.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/services/formatter.ts b/src/services/formatter.ts index 28b36c3..09febb8 100644 --- a/src/services/formatter.ts +++ b/src/services/formatter.ts @@ -1,4 +1,4 @@ -import type { Card, Deck, DeckCard, OutputFormat, SkillOutcome } from "../types/index.js"; +import type { Card, Deck, DeckCard, MetaResult, OutputFormat, RankedRow, SkillOutcome } from "../types/index.js"; import { getFormatKo, getHeroClassKo } from "./deck-decoder.js"; const buildManaCurve = (cards: readonly DeckCard[]): Record => { @@ -117,3 +117,36 @@ export const formatSkillOutcomes = ( ) .join("\n"); }; + +const pct = (v: number): string => `${(v * 100).toFixed(1)}%`; + +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 < 1000 ? " [⚠ low sample]" : ""; + const header = `Data: Firestone (firestoneapp.com) · ${result.gameFormat}/${result.rank}/${result.period} · updated ${result.lastUpdated} · ${result.dataPoints} games${lowSampleFlag}`; + 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/tests/meta-stats.test.ts b/tests/meta-stats.test.ts index 2701e9a..cd38467 100644 --- a/tests/meta-stats.test.ts +++ b/tests/meta-stats.test.ts @@ -1,7 +1,8 @@ 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 } from "../src/types/index.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" }; @@ -51,3 +52,36 @@ describe("rankDecks", () => { 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 when dataPoints < 1000", () => { + const lowResult = { ...sampleResult, dataPoints: 500 }; + expect(formatMetaStats(lowResult, sampleRows, "table")).toContain("low sample"); + }); +}); From bdb78f1b982d3b5ab2645a12b91228c85cbf0a1e Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:25:52 +0900 Subject: [PATCH 06/16] feat: wire hs meta archetypes/decks (Firestone live stats) into meta command --- src/commands/meta.ts | 127 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 110 insertions(+), 17 deletions(-) diff --git a/src/commands/meta.ts b/src/commands/meta.ts index 0166204..1722fd2 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,83 @@ 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(", ")}`); } - try { + if ((STATIC_TYPES as readonly string[]).includes(type)) { const locale = resolveLocale(args.locale); - const values = await getMetadata(type as MetaType, locale); + const values = await getMetadata(type as (typeof STATIC_TYPES)[number], 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); + 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 names = await loadArchetypeNames(); + const opts: RankOptions = { sort, minGames, limit: format === "json" ? undefined : limit }; + + try { + 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)}`, + ); } }, }); From 0147cf5ee11d645bd9a8eb4f85423df9ea82ab5d Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:29:36 +0900 Subject: [PATCH 07/16] fix: wrap static meta branch in try/catch + format meta command --- src/commands/meta.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/commands/meta.ts b/src/commands/meta.ts index 1722fd2..69e8ec9 100644 --- a/src/commands/meta.ts +++ b/src/commands/meta.ts @@ -105,29 +105,36 @@ export const metaCommand = defineCommand({ } if ((STATIC_TYPES as readonly string[]).includes(type)) { - const locale = resolveLocale(args.locale); - const values = await getMetadata(type as (typeof STATIC_TYPES)[number], locale); - process.stdout.write(`${formatMeta(type, values, format)}\n`); + 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 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 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 names = await loadArchetypeNames(); const opts: RankOptions = { sort, minGames, limit: format === "json" ? undefined : limit }; try { + const names = await loadArchetypeNames(); if (kind === "archetypes") { const result = await fetchMeta("archetypes", gameFormat, rank, period); const rows = rankArchetypes(result.rows, names, opts); From 992968751e09b3e7421cf3a936ea8f7cc122f148 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:32:23 +0900 Subject: [PATCH 08/16] docs: document hs meta archetypes/decks + Firestone attribution --- CLAUDE.md | 3 +++ README.es.md | 13 +++++++++++++ README.ja.md | 13 +++++++++++++ README.ko.md | 13 +++++++++++++ README.md | 13 +++++++++++++ README.zh.md | 13 +++++++++++++ plugins/hs-cli/README.md | 14 ++++++++++++++ 7 files changed, 82 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3395d62..7ebd2de 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 ``` @@ -77,6 +79,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) From 317b8171466923c8a820ea484b9f79b11b35df75 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:33:26 +0900 Subject: [PATCH 09/16] style: oxfmt normalize formatter + stats-math --- src/services/formatter.ts | 10 +++++++++- src/services/stats-math.ts | 3 +-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/services/formatter.ts b/src/services/formatter.ts index 09febb8..f7fafca 100644 --- a/src/services/formatter.ts +++ b/src/services/formatter.ts @@ -1,4 +1,12 @@ -import type { Card, Deck, DeckCard, MetaResult, OutputFormat, RankedRow, SkillOutcome } from "../types/index.js"; +import type { + Card, + Deck, + DeckCard, + MetaResult, + OutputFormat, + RankedRow, + SkillOutcome, +} from "../types/index.js"; import { getFormatKo, getHeroClassKo } from "./deck-decoder.js"; const buildManaCurve = (cards: readonly DeckCard[]): Record => { diff --git a/src/services/stats-math.ts b/src/services/stats-math.ts index dd6a1d9..96addc8 100644 --- a/src/services/stats-math.ts +++ b/src/services/stats-math.ts @@ -12,8 +12,7 @@ export const wilsonLower = (wins: number, games: number, z = 1.96): number => { // 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); +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). From 313186032edf8fb1312b94d7f98e25edc1284e48 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:34:11 +0900 Subject: [PATCH 10/16] style: oxfmt normalize meta-stats fixtures and test --- tests/fixtures/firestone-archetypes.json | 40 +++++++++++++++++++++--- tests/fixtures/firestone-decks.json | 20 ++++++++++-- tests/meta-stats.test.ts | 21 ++++++++----- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/tests/fixtures/firestone-archetypes.json b/tests/fixtures/firestone-archetypes.json index f42a5f3..a5dbaa8 100644 --- a/tests/fixtures/firestone-archetypes.json +++ b/tests/fixtures/firestone-archetypes.json @@ -5,9 +5,41 @@ "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.80 }, - { "id": 4, "name": "druid", "heroCardClass": "druid", "totalGames": 2500, "totalWins": 1200, "coreCards": [], "winrate": 0.48 } + { + "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 index c5b490b..df20621 100644 --- a/tests/fixtures/firestone-decks.json +++ b/tests/fixtures/firestone-decks.json @@ -5,7 +5,23 @@ "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 } + { + "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/meta-stats.test.ts b/tests/meta-stats.test.ts index cd38467..51acae0 100644 --- a/tests/meta-stats.test.ts +++ b/tests/meta-stats.test.ts @@ -6,12 +6,10 @@ 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[]; +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", () => { @@ -62,7 +60,16 @@ const sampleResult: MetaResult = { 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" }, + { + displayName: "Mech Rogue", + playerClass: "rogue", + winrate: 0.55, + wilsonLower: 0.541, + moe: 0.014, + tier: "A", + totalGames: 5000, + deckcode: "AAECCODE_A", + }, ]; describe("formatMetaStats", () => { From 3251449e9cb793b7ccb0b3382ed224515321f20c Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 20:39:58 +0900 Subject: [PATCH 11/16] refactor: drop unused isOtherBucket/CLASS_SLUGS, guard non-finite winrate --- src/services/archetype-names.ts | 20 -------------------- src/services/meta-stats.ts | 9 +++++++-- tests/archetype-names.test.ts | 11 +---------- 3 files changed, 8 insertions(+), 32 deletions(-) diff --git a/src/services/archetype-names.ts b/src/services/archetype-names.ts index 6dd8c2c..ad34092 100644 --- a/src/services/archetype-names.ts +++ b/src/services/archetype-names.ts @@ -8,31 +8,11 @@ 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 CLASS_SLUGS: readonly string[] = [ - "deathknight", - "demonhunter", - "druid", - "hunter", - "mage", - "paladin", - "priest", - "rogue", - "shaman", - "warlock", - "warrior", -]; - export const displayName = (slug: string, map: Record): string => { const key = slug.startsWith("std-") ? slug.slice(4) : slug; return map[key] ?? slug; }; -// A bare class-name slug (optionally with "xl"/dashes) is Firestone's catch-all bucket. -export const isOtherBucket = (slug: string): boolean => { - const norm = slug.toLowerCase().replaceAll("xl", "").replaceAll("-", "").trim(); - return CLASS_SLUGS.includes(norm); -}; - const isFresh = async (): Promise => { try { const s = await stat(CACHE_FILE); diff --git a/src/services/meta-stats.ts b/src/services/meta-stats.ts index f0d2d06..041ab63 100644 --- a/src/services/meta-stats.ts +++ b/src/services/meta-stats.ts @@ -31,6 +31,11 @@ export interface RankOptions { 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; @@ -53,7 +58,7 @@ export const rankArchetypes = ( return { displayName: displayName(s.name, names), playerClass: s.heroCardClass, - winrate: s.winrate, + winrate: safeWinrate(s.winrate, s.totalWins, s.totalGames), wilsonLower: wl, moe: marginOfError(s.totalGames), tier: tierBand(wl * 100), @@ -73,7 +78,7 @@ export const rankDecks = ( return { displayName: displayName(s.archetypeName, names), playerClass: s.playerClass, - winrate: s.winrate, + winrate: safeWinrate(s.winrate, s.totalWins, s.totalGames), wilsonLower: wl, moe: marginOfError(s.totalGames), tier: tierBand(wl * 100), diff --git a/tests/archetype-names.test.ts b/tests/archetype-names.test.ts index fd6a0ff..991d315 100644 --- a/tests/archetype-names.test.ts +++ b/tests/archetype-names.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { readFileSync } from "node:fs"; -import { CLASS_SLUGS, displayName, isOtherBucket } from "../src/services/archetype-names.ts"; +import { displayName } from "../src/services/archetype-names.ts"; const map = ( JSON.parse(readFileSync("tests/fixtures/firestone-translations.json", "utf8")) as { @@ -18,13 +18,4 @@ describe("archetype-names", () => { it("falls back to the raw slug when unmapped", () => { expect(displayName("brand-new-deck", map)).toBe("brand-new-deck"); }); - it("detects catch-all class-name buckets", () => { - expect(isOtherBucket("druid")).toBe(true); - expect(isOtherBucket("xl-druid")).toBe(true); - expect(isOtherBucket("mech-rogue")).toBe(false); - }); - it("exposes the 11 class slugs", () => { - expect(CLASS_SLUGS.includes("deathknight")).toBe(true); - expect(CLASS_SLUGS.length).toBe(11); - }); }); From 631528a04bf511aef7c2f71143f190176a5bac67 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 21:19:46 +0900 Subject: [PATCH 12/16] feat: show no-rows message in meta table when min-games filters everything --- src/services/formatter.ts | 3 +++ tests/meta-stats.test.ts | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/src/services/formatter.ts b/src/services/formatter.ts index f7fafca..b475aed 100644 --- a/src/services/formatter.ts +++ b/src/services/formatter.ts @@ -152,6 +152,9 @@ export const formatMetaStats = ( } const lowSampleFlag = result.dataPoints < 1000 ? " [⚠ 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; diff --git a/tests/meta-stats.test.ts b/tests/meta-stats.test.ts index 51acae0..5b1a1f8 100644 --- a/tests/meta-stats.test.ts +++ b/tests/meta-stats.test.ts @@ -91,4 +91,10 @@ describe("formatMetaStats", () => { const lowResult = { ...sampleResult, dataPoints: 500 }; expect(formatMetaStats(lowResult, sampleRows, "table")).toContain("low sample"); }); + + 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"); + }); }); From dc84cfecf9d4dcbcc3feb5d6b2c334600e348e15 Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 21:47:28 +0900 Subject: [PATCH 13/16] refactor: .ts import extensions + meaningful low-sample threshold (20k) --- src/services/formatter.ts | 11 ++++++++--- tests/meta-stats.test.ts | 9 +++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/services/formatter.ts b/src/services/formatter.ts index b475aed..0924099 100644 --- a/src/services/formatter.ts +++ b/src/services/formatter.ts @@ -6,8 +6,8 @@ import type { OutputFormat, RankedRow, SkillOutcome, -} from "../types/index.js"; -import { getFormatKo, getHeroClassKo } from "./deck-decoder.js"; +} from "../types/index.ts"; +import { getFormatKo, getHeroClassKo } from "./deck-decoder.ts"; const buildManaCurve = (cards: readonly DeckCard[]): Record => { const curve: Record = {}; @@ -128,6 +128,11 @@ export const formatSkillOutcomes = ( 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[], @@ -150,7 +155,7 @@ export const formatMetaStats = ( 2, ); } - const lowSampleFlag = result.dataPoints < 1000 ? " [⚠ low sample]" : ""; + 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)`; diff --git a/tests/meta-stats.test.ts b/tests/meta-stats.test.ts index 5b1a1f8..f6f849a 100644 --- a/tests/meta-stats.test.ts +++ b/tests/meta-stats.test.ts @@ -87,11 +87,16 @@ describe("formatMetaStats", () => { expect(out).toContain("AAECCODE_A"); expect(out).toContain("A"); }); - it("table output flags low sample when dataPoints < 1000", () => { - const lowResult = { ...sampleResult, dataPoints: 500 }; + 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("table output shows a no-rows message when nothing meets the threshold", () => { const out = formatMetaStats(sampleResult, [], "table"); expect(out).toContain("Firestone"); From daa0f6c0570a87a307606e62b427f7f7c4b3c4cb Mon Sep 17 00:00:00 2001 From: Penguin Date: Fri, 29 May 2026 21:47:28 +0900 Subject: [PATCH 14/16] docs(skill): document hs meta archetypes/decks live-meta in hearthstone-deck skill --- .../hs-cli/skills/hearthstone-deck/SKILL.md | 4 +- .../skills/hearthstone-deck/recipes/meta.md | 56 ++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) 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). From e4b6e712c39685134add4a795fd1c8b0813dacb2 Mon Sep 17 00:00:00 2001 From: Penguin Date: Sat, 30 May 2026 17:49:16 +0900 Subject: [PATCH 15/16] docs: require updating the hearthstone-deck skill on any CLI-surface change --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7ebd2de..65f99e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,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 From 793070c2f10a87984039fd87ec4719817c73ee51 Mon Sep 17 00:00:00 2001 From: Penguin Date: Sat, 30 May 2026 21:47:08 +0900 Subject: [PATCH 16/16] test: pin low-sample boundary + json empty-rows contract --- tests/meta-stats.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/meta-stats.test.ts b/tests/meta-stats.test.ts index f6f849a..d1542ba 100644 --- a/tests/meta-stats.test.ts +++ b/tests/meta-stats.test.ts @@ -97,6 +97,17 @@ describe("formatMetaStats", () => { 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");