fablazing-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fablazing_cli/__init__.py +1 -0
- fablazing_cli/client.py +52 -0
- fablazing_cli/commands/__init__.py +0 -0
- fablazing_cli/commands/auth.py +43 -0
- fablazing_cli/commands/card.py +141 -0
- fablazing_cli/commands/hero.py +91 -0
- fablazing_cli/commands/matchup.py +121 -0
- fablazing_cli/commands/meta.py +76 -0
- fablazing_cli/commands/tournaments.py +120 -0
- fablazing_cli/config.py +39 -0
- fablazing_cli/main.py +41 -0
- fablazing_cli/output.py +97 -0
- fablazing_cli/reference.py +61 -0
- fablazing_cli/resolve.py +87 -0
- fablazing_cli-0.1.0.dist-info/METADATA +67 -0
- fablazing_cli-0.1.0.dist-info/RECORD +18 -0
- fablazing_cli-0.1.0.dist-info/WHEEL +4 -0
- fablazing_cli-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
fablazing_cli/client.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""HTTP client for the v1 API. Exits 1 on API errors, 3 on quota."""
|
|
2
|
+
import httpx
|
|
3
|
+
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .config import api_key, api_url
|
|
6
|
+
from .output import fail
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get(path: str, params: dict | None = None) -> dict:
|
|
10
|
+
key = api_key()
|
|
11
|
+
if not key:
|
|
12
|
+
fail(
|
|
13
|
+
"no API key configured. Run: fablazing auth set-key "
|
|
14
|
+
"(or set the FABLAZING_API_KEY environment variable)"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
clean = {}
|
|
18
|
+
for name, value in (params or {}).items():
|
|
19
|
+
if value is None or value == []:
|
|
20
|
+
continue
|
|
21
|
+
clean[name] = value
|
|
22
|
+
|
|
23
|
+
url = f"{api_url()}{path}"
|
|
24
|
+
try:
|
|
25
|
+
response = httpx.get(
|
|
26
|
+
url,
|
|
27
|
+
params=clean,
|
|
28
|
+
headers={
|
|
29
|
+
"Authorization": f"Bearer {key}",
|
|
30
|
+
"User-Agent": f"fablazing-cli/{__version__}",
|
|
31
|
+
},
|
|
32
|
+
timeout=120,
|
|
33
|
+
)
|
|
34
|
+
except httpx.HTTPError as exc:
|
|
35
|
+
fail(f"cannot reach the API at {api_url()}: {exc}")
|
|
36
|
+
|
|
37
|
+
if response.status_code == 401:
|
|
38
|
+
fail("invalid or revoked API key (401). Run: fablazing auth set-key")
|
|
39
|
+
if response.status_code == 429:
|
|
40
|
+
detail = _detail(response) or "rate limited"
|
|
41
|
+
fail(f"{detail}", 3)
|
|
42
|
+
if response.status_code >= 400:
|
|
43
|
+
fail(f"API error {response.status_code}: {_detail(response)}")
|
|
44
|
+
|
|
45
|
+
return response.json()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _detail(response: httpx.Response) -> str:
|
|
49
|
+
try:
|
|
50
|
+
return response.json().get("detail", "")
|
|
51
|
+
except ValueError:
|
|
52
|
+
return response.text[:200]
|
|
File without changes
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""API key management."""
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
from .. import client
|
|
5
|
+
from ..config import api_key, api_url, config_path, load_config, save_config
|
|
6
|
+
from ..output import console, fail
|
|
7
|
+
|
|
8
|
+
app = typer.Typer(help="API key management")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@app.command("set-key")
|
|
12
|
+
def set_key(key: str = typer.Argument(None, help="Your fbl_live_... API key")):
|
|
13
|
+
"""Store your API key (prompted securely if not passed as an argument)."""
|
|
14
|
+
if not key:
|
|
15
|
+
key = typer.prompt("API key", hide_input=True)
|
|
16
|
+
key = key.strip()
|
|
17
|
+
if not key.startswith("fbl_live_"):
|
|
18
|
+
fail("that doesn't look like a Fablazing API key (expected fbl_live_...)", 2)
|
|
19
|
+
cfg = load_config()
|
|
20
|
+
cfg["api_key"] = key
|
|
21
|
+
save_config(cfg)
|
|
22
|
+
console.print(f"Key saved to {config_path()}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("set-url")
|
|
26
|
+
def set_url(url: str = typer.Argument(..., help="API base URL (for self-hosted/dev)")):
|
|
27
|
+
"""Point the CLI at a different API base URL."""
|
|
28
|
+
cfg = load_config()
|
|
29
|
+
cfg["api_url"] = url.rstrip("/")
|
|
30
|
+
save_config(cfg)
|
|
31
|
+
console.print(f"API URL set to {cfg['api_url']}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def status():
|
|
36
|
+
"""Verify the configured key against the API."""
|
|
37
|
+
key = api_key()
|
|
38
|
+
if not key:
|
|
39
|
+
fail("no API key configured. Run: fablazing auth set-key")
|
|
40
|
+
masked = key[:15] + "..."
|
|
41
|
+
heroes = client.get("/heroes")
|
|
42
|
+
count = heroes.get("meta", {}).get("count", "?")
|
|
43
|
+
console.print(f"[green]OK[/green] key {masked} against {api_url()} ({count} heroes visible)")
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Card search, metadata, trends, synergies, popularity."""
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
from .. import client, resolve
|
|
5
|
+
from ..output import console, emit, pct, set_mode
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Card catalog and analytics")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def search(
|
|
12
|
+
query: str = typer.Argument(..., help="Part of a card name"),
|
|
13
|
+
limit: int = typer.Option(25, "--limit", min=1, max=100),
|
|
14
|
+
json_: bool = typer.Option(False, "--json"),
|
|
15
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
16
|
+
):
|
|
17
|
+
"""Search cards and equipment by name."""
|
|
18
|
+
set_mode(json_, csv_)
|
|
19
|
+
payload = client.get("/cards/search", {"q": query, "limit": limit})
|
|
20
|
+
rows = [
|
|
21
|
+
[
|
|
22
|
+
c.get("card_id", ""),
|
|
23
|
+
c.get("card_name", ""),
|
|
24
|
+
c.get("pitch_value", ""),
|
|
25
|
+
"equipment" if c.get("is_equipment") else "card",
|
|
26
|
+
c.get("class", ""),
|
|
27
|
+
f"${c['tcg_market_price']:.2f}" if c.get("tcg_market_price") else "-",
|
|
28
|
+
]
|
|
29
|
+
for c in payload["data"]
|
|
30
|
+
]
|
|
31
|
+
emit(payload, tables=[("Results", ["ID", "Name", "Pitch", "Kind", "Class", "Price"], rows)])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def show(
|
|
36
|
+
card: str = typer.Argument(..., help="Card id or name"),
|
|
37
|
+
json_: bool = typer.Option(False, "--json"),
|
|
38
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
39
|
+
):
|
|
40
|
+
"""Show one card's metadata."""
|
|
41
|
+
set_mode(json_, csv_)
|
|
42
|
+
card_id = resolve.card_id(card)
|
|
43
|
+
payload = client.get(f"/cards/{card_id}")
|
|
44
|
+
data = payload["data"]
|
|
45
|
+
rows = [
|
|
46
|
+
[field, str(data.get(field, ""))]
|
|
47
|
+
for field in (
|
|
48
|
+
"card_id", "card_name", "kind", "pitch_value", "cost", "power",
|
|
49
|
+
"defense", "class", "rarity", "tcg_market_price", "default_set_name",
|
|
50
|
+
)
|
|
51
|
+
if data.get(field) not in (None, "")
|
|
52
|
+
]
|
|
53
|
+
emit(payload, tables=[(data.get("card_name", card_id), ["Field", "Value"], rows)])
|
|
54
|
+
if data.get("text"):
|
|
55
|
+
console.print(f"[dim]{data['text']}[/dim]")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@app.command()
|
|
59
|
+
def trends(
|
|
60
|
+
card: str = typer.Argument(..., help="Card id or name"),
|
|
61
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
62
|
+
days: int = typer.Option(30, "--days", min=1, max=365),
|
|
63
|
+
json_: bool = typer.Option(False, "--json"),
|
|
64
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
65
|
+
):
|
|
66
|
+
"""Daily play-rate time series."""
|
|
67
|
+
set_mode(json_, csv_)
|
|
68
|
+
card_id = resolve.card_id(card)
|
|
69
|
+
payload = client.get(f"/cards/{card_id}/trends", {"format": format_type, "days": days})
|
|
70
|
+
data = payload["data"]
|
|
71
|
+
points = data.get("data_points") or data.get("trends") or []
|
|
72
|
+
rows = [
|
|
73
|
+
[
|
|
74
|
+
p.get("date", ""),
|
|
75
|
+
pct(p.get("deck_frequency")),
|
|
76
|
+
p.get("deck_count", ""),
|
|
77
|
+
p.get("match_count", ""),
|
|
78
|
+
]
|
|
79
|
+
for p in points
|
|
80
|
+
]
|
|
81
|
+
summary = []
|
|
82
|
+
if data.get("trend_direction"):
|
|
83
|
+
summary.append(
|
|
84
|
+
f"[bold]{card_id}[/bold] ({format_type}, {days}d): trend {data['trend_direction']}"
|
|
85
|
+
+ (f", change {data.get('percent_change'):+.1f}%" if data.get("percent_change") is not None else "")
|
|
86
|
+
)
|
|
87
|
+
emit(payload, tables=[("Daily", ["Date", "Deck freq", "Decks", "Matches"], rows)], summary=summary)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.command()
|
|
91
|
+
def synergies(
|
|
92
|
+
card: str = typer.Argument(..., help="Card id or name"),
|
|
93
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
94
|
+
limit: int = typer.Option(20, "--limit", min=1, max=50),
|
|
95
|
+
json_: bool = typer.Option(False, "--json"),
|
|
96
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
97
|
+
):
|
|
98
|
+
"""Co-occurrence synergy partners, with scores."""
|
|
99
|
+
set_mode(json_, csv_)
|
|
100
|
+
card_id = resolve.card_id(card)
|
|
101
|
+
payload = client.get(f"/cards/{card_id}/synergies", {"format": format_type, "limit": limit})
|
|
102
|
+
rows = [
|
|
103
|
+
[
|
|
104
|
+
s.get("card_id", ""),
|
|
105
|
+
s.get("card_name", ""),
|
|
106
|
+
f"{s.get('synergy_score', 0):.3f}",
|
|
107
|
+
s.get("co_occurrence_count", ""),
|
|
108
|
+
f"${s['tcg_market_price']:.2f}" if s.get("tcg_market_price") else "-",
|
|
109
|
+
]
|
|
110
|
+
for s in payload["data"].get("synergies", [])
|
|
111
|
+
]
|
|
112
|
+
emit(payload, tables=[(f"Synergies for {card_id}", ["ID", "Name", "Score", "Co-occur", "Price"], rows)])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command()
|
|
116
|
+
def popular(
|
|
117
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
118
|
+
days: int = typer.Option(7, "--days", min=1, max=365),
|
|
119
|
+
limit: int = typer.Option(25, "--limit", min=1, max=100),
|
|
120
|
+
json_: bool = typer.Option(False, "--json"),
|
|
121
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
122
|
+
):
|
|
123
|
+
"""Most-played cards over the window."""
|
|
124
|
+
set_mode(json_, csv_)
|
|
125
|
+
payload = client.get("/cards/popular", {"format": format_type, "days": days, "limit": limit})
|
|
126
|
+
rows = [
|
|
127
|
+
[
|
|
128
|
+
c.get("rank", ""),
|
|
129
|
+
c.get("card_id", ""),
|
|
130
|
+
c.get("card_name", ""),
|
|
131
|
+
pct(c.get("avg_deck_frequency")),
|
|
132
|
+
c.get("total_deck_count", ""),
|
|
133
|
+
c.get("total_matches", ""),
|
|
134
|
+
]
|
|
135
|
+
for c in payload["data"].get("cards", [])
|
|
136
|
+
]
|
|
137
|
+
emit(
|
|
138
|
+
payload,
|
|
139
|
+
tables=[("Popular cards", ["#", "ID", "Name", "Deck freq", "Decks", "Matches"], rows)],
|
|
140
|
+
summary=[f"[bold]Most played[/bold] - {format_type}, last {days}d"],
|
|
141
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Hero catalog + pre-computed matchup aggregates."""
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
from .. import client, resolve
|
|
5
|
+
from ..output import emit, pct, set_mode
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Heroes and their matchup tables")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command("list")
|
|
11
|
+
def list_heroes(
|
|
12
|
+
hero_class: str = typer.Option(None, "--class", help="Filter by class, e.g. Mechanologist"),
|
|
13
|
+
json_: bool = typer.Option(False, "--json"),
|
|
14
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
15
|
+
):
|
|
16
|
+
"""List all heroes."""
|
|
17
|
+
set_mode(json_, csv_)
|
|
18
|
+
payload = client.get("/heroes", {"class": hero_class})
|
|
19
|
+
rows = [
|
|
20
|
+
[h["hero_id"], h.get("hero_name", ""), ", ".join(h.get("class", []) if isinstance(h.get("class"), list) else [str(h.get("class", ""))]), h.get("life", ""), h.get("intellect", "")]
|
|
21
|
+
for h in payload["data"]
|
|
22
|
+
]
|
|
23
|
+
emit(payload, tables=[("Heroes", ["ID", "Name", "Class", "Life", "Int"], rows)])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def show(
|
|
28
|
+
hero: str = typer.Argument(..., help="Hero id or name"),
|
|
29
|
+
json_: bool = typer.Option(False, "--json"),
|
|
30
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
31
|
+
):
|
|
32
|
+
"""Show one hero's details."""
|
|
33
|
+
set_mode(json_, csv_)
|
|
34
|
+
hero_id = resolve.hero_id(hero)
|
|
35
|
+
payload = client.get(f"/heroes/{hero_id}")
|
|
36
|
+
data = payload["data"]
|
|
37
|
+
rows = [
|
|
38
|
+
[field, str(data.get(field, ""))]
|
|
39
|
+
for field in ("hero_id", "hero_name", "class", "life", "intellect", "rarity", "tcg_market_price")
|
|
40
|
+
if data.get(field) is not None
|
|
41
|
+
]
|
|
42
|
+
emit(payload, tables=[(data.get("hero_name", hero_id), ["Field", "Value"], rows)])
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command()
|
|
46
|
+
def aggregate(
|
|
47
|
+
hero: str = typer.Argument(..., help="Hero id or name"),
|
|
48
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
49
|
+
source: str = typer.Option("online", "--source", help="online | paper"),
|
|
50
|
+
period: str = typer.Option("14d", "--period", help="7d | 14d | 30d"),
|
|
51
|
+
json_: bool = typer.Option(False, "--json"),
|
|
52
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
53
|
+
):
|
|
54
|
+
"""Pre-computed matchup table: per-opponent record + play/draw splits."""
|
|
55
|
+
set_mode(json_, csv_)
|
|
56
|
+
hero_id = resolve.hero_id(hero)
|
|
57
|
+
payload = client.get(
|
|
58
|
+
f"/heroes/{hero_id}/aggregate",
|
|
59
|
+
{"format": format_type, "source": source, "period": period},
|
|
60
|
+
)
|
|
61
|
+
data = payload["data"]
|
|
62
|
+
names = resolve.hero_names()
|
|
63
|
+
|
|
64
|
+
matchups = sorted(
|
|
65
|
+
data.get("matchups", {}).items(),
|
|
66
|
+
key=lambda kv: kv[1].get("total_matches", 0),
|
|
67
|
+
reverse=True,
|
|
68
|
+
)
|
|
69
|
+
rows = [
|
|
70
|
+
[
|
|
71
|
+
names.get(opp_id, opp_id),
|
|
72
|
+
opp_id,
|
|
73
|
+
m.get("total_matches", 0),
|
|
74
|
+
pct(m.get("win_rate")),
|
|
75
|
+
pct(m.get("going_first_win_rate")),
|
|
76
|
+
pct(m.get("going_second_win_rate")),
|
|
77
|
+
]
|
|
78
|
+
for opp_id, m in matchups
|
|
79
|
+
]
|
|
80
|
+
summary = [
|
|
81
|
+
f"[bold]{names.get(hero_id, hero_id)}[/bold] - {format_type}/{source}/{period}: "
|
|
82
|
+
f"{data.get('total_matches', 0)} matches, "
|
|
83
|
+
f"overall WR {pct(data.get('overall_win_rate'))} "
|
|
84
|
+
f"(first {pct(data.get('going_first_win_rate'))}, "
|
|
85
|
+
f"second {pct(data.get('going_second_win_rate'))})"
|
|
86
|
+
]
|
|
87
|
+
emit(
|
|
88
|
+
payload,
|
|
89
|
+
tables=[("Matchups", ["Opponent", "ID", "Matches", "WR", "First WR", "Second WR"], rows)],
|
|
90
|
+
summary=summary,
|
|
91
|
+
)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""The matchup command."""
|
|
2
|
+
from typing import List
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from .. import client, resolve
|
|
7
|
+
from ..output import emit, pct, set_mode
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def matchup(
|
|
11
|
+
hero1: str = typer.Argument(..., help="Deck 1 hero (id or name)"),
|
|
12
|
+
hero2: str = typer.Argument(..., help="Deck 2 hero (id or name)"),
|
|
13
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
14
|
+
days: int = typer.Option(30, "--days", min=1, max=365),
|
|
15
|
+
start_date: str = typer.Option(None, "--start", help="YYYY-MM-DD (overrides --days)"),
|
|
16
|
+
end_date: str = typer.Option(None, "--end", help="YYYY-MM-DD"),
|
|
17
|
+
winner: int = typer.Option(None, "--winner", min=1, max=2, help="Only games won by deck 1 or 2"),
|
|
18
|
+
include1: List[str] = typer.Option([], "--include1", help="Deck 1 must run this card (repeatable; supports card:gte2)"),
|
|
19
|
+
include2: List[str] = typer.Option([], "--include2", help="Deck 2 must run this card"),
|
|
20
|
+
exclude1: List[str] = typer.Option([], "--exclude1", help="Deck 1 must NOT run this card"),
|
|
21
|
+
exclude2: List[str] = typer.Option([], "--exclude2", help="Deck 2 must NOT run this card"),
|
|
22
|
+
dynamics: bool = typer.Option(True, "--dynamics/--no-dynamics", help="Game length / fatigue stats"),
|
|
23
|
+
top: int = typer.Option(10, "--top", min=1, max=50, help="Cards per table"),
|
|
24
|
+
json_: bool = typer.Option(False, "--json"),
|
|
25
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
26
|
+
):
|
|
27
|
+
"""Head-to-head analysis: win rates, play/draw splits, card tables, tech
|
|
28
|
+
cards, and game dynamics."""
|
|
29
|
+
set_mode(json_, csv_)
|
|
30
|
+
h1 = resolve.hero_id(hero1)
|
|
31
|
+
h2 = resolve.hero_id(hero2)
|
|
32
|
+
params = {
|
|
33
|
+
"format": format_type,
|
|
34
|
+
"days": days,
|
|
35
|
+
"start_date": start_date,
|
|
36
|
+
"end_date": end_date,
|
|
37
|
+
"winner": winner,
|
|
38
|
+
"included_deck1": [resolve.card_id(c, allow_spec=True) for c in include1],
|
|
39
|
+
"included_deck2": [resolve.card_id(c, allow_spec=True) for c in include2],
|
|
40
|
+
"excluded_deck1": [resolve.card_id(c) for c in exclude1],
|
|
41
|
+
"excluded_deck2": [resolve.card_id(c) for c in exclude2],
|
|
42
|
+
"include_dynamics": dynamics,
|
|
43
|
+
}
|
|
44
|
+
payload = client.get(f"/matchups/{h1}/{h2}", params)
|
|
45
|
+
data = payload["data"]
|
|
46
|
+
names = resolve.hero_names()
|
|
47
|
+
name1, name2 = names.get(h1, h1), names.get(h2, h2)
|
|
48
|
+
|
|
49
|
+
total = data.get("total_matches", 0)
|
|
50
|
+
summary = [
|
|
51
|
+
f"[bold]{name1}[/bold] vs [bold]{name2}[/bold] - {format_type}, {total} matches"
|
|
52
|
+
]
|
|
53
|
+
if total == 0:
|
|
54
|
+
emit(payload, summary=summary + ["No matches found for these filters."])
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
overview_rows = []
|
|
58
|
+
for deck_key, name in (("deck1", name1), ("deck2", name2)):
|
|
59
|
+
overview_rows.append([
|
|
60
|
+
name,
|
|
61
|
+
data.get(f"{deck_key}_wins", 0),
|
|
62
|
+
pct(data.get(f"{deck_key}_win_rate")),
|
|
63
|
+
f"{pct(data.get(f'{deck_key}_going_first_win_rate'))} ({data.get(f'{deck_key}_going_first_matches', 0)})",
|
|
64
|
+
f"{pct(data.get(f'{deck_key}_going_second_win_rate'))} ({data.get(f'{deck_key}_going_second_matches', 0)})",
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
tables = [("Overview", ["Deck", "Wins", "WR", "First WR (n)", "Second WR (n)"], overview_rows)]
|
|
68
|
+
|
|
69
|
+
def card_rows(cards):
|
|
70
|
+
return [
|
|
71
|
+
[
|
|
72
|
+
c.get("card_id", ""),
|
|
73
|
+
pct(c.get("frequency")),
|
|
74
|
+
pct(c.get("win_rate")),
|
|
75
|
+
pct(c.get("play_frequency")),
|
|
76
|
+
pct(c.get("block_frequency")),
|
|
77
|
+
pct(c.get("pitch_frequency")),
|
|
78
|
+
f"{c.get('copies_avg', 0):.1f}",
|
|
79
|
+
]
|
|
80
|
+
for c in (cards or [])[:top]
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
card_columns = ["Card", "Freq", "WR", "Play", "Block", "Pitch", "Copies"]
|
|
84
|
+
for deck_key, name in (("deck1", name1), ("deck2", name2)):
|
|
85
|
+
frequent = data.get(f"{deck_key}_frequent_cards")
|
|
86
|
+
if frequent:
|
|
87
|
+
tables.append((f"{name} - top cards", card_columns, card_rows(frequent)))
|
|
88
|
+
tech = data.get(f"{deck_key}_tech_cards")
|
|
89
|
+
if tech:
|
|
90
|
+
tech_rows = [
|
|
91
|
+
[
|
|
92
|
+
c.get("card_id", ""),
|
|
93
|
+
pct(c.get("frequency")),
|
|
94
|
+
pct(c.get("win_rate")),
|
|
95
|
+
f"+{c.get('wilson_improvement', 0) * 100:.1f}%",
|
|
96
|
+
c.get("match_count", ""),
|
|
97
|
+
]
|
|
98
|
+
for c in tech[:top]
|
|
99
|
+
]
|
|
100
|
+
tables.append((f"{name} - tech cards (Wilson outperformers)", ["Card", "Freq", "WR", "Wilson delta", "Matches"], tech_rows))
|
|
101
|
+
|
|
102
|
+
game_length = data.get("game_length") or {}
|
|
103
|
+
turns = game_length.get("turns") or {}
|
|
104
|
+
if turns:
|
|
105
|
+
percentiles = turns.get("percentiles") or {}
|
|
106
|
+
spread = ""
|
|
107
|
+
if percentiles.get("p10") is not None and percentiles.get("p90") is not None:
|
|
108
|
+
spread = f", p10-p90 {percentiles['p10']}-{percentiles['p90']}"
|
|
109
|
+
summary.append(
|
|
110
|
+
f"Game length: avg {turns.get('avg', '?')} turns, "
|
|
111
|
+
f"median {turns.get('median', '?')}{spread}"
|
|
112
|
+
)
|
|
113
|
+
for deck_key, name in (("deck1", name1), ("deck2", name2)):
|
|
114
|
+
transform = data.get(f"{deck_key}_transform")
|
|
115
|
+
if transform and transform.get("can_transform"):
|
|
116
|
+
summary.append(
|
|
117
|
+
f"{name} transform rate: {pct(transform.get('transform_rate'))} "
|
|
118
|
+
f"({transform.get('transformed_matches', 0)}/{transform.get('assessed_matches', 0)} games)"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
emit(payload, tables=tables, summary=summary)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Meta snapshot + movers."""
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
from .. import client
|
|
5
|
+
from ..output import emit, pct, set_mode
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Meta breakdown and trending heroes")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command()
|
|
11
|
+
def snapshot(
|
|
12
|
+
days: int = typer.Option(14, "--days", min=1, max=90),
|
|
13
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
14
|
+
source: str = typer.Option("online", "--source", help="online | paper"),
|
|
15
|
+
tournament_type: str = typer.Option(None, "--type", help="Paper only, e.g. Calling"),
|
|
16
|
+
json_: bool = typer.Option(False, "--json"),
|
|
17
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
18
|
+
):
|
|
19
|
+
"""Hero popularity, win rate, and top matchups over the window."""
|
|
20
|
+
set_mode(json_, csv_)
|
|
21
|
+
payload = client.get(
|
|
22
|
+
"/meta/snapshot",
|
|
23
|
+
{"days": days, "format": format_type, "source": source, "tournament_type": tournament_type},
|
|
24
|
+
)
|
|
25
|
+
data = payload["data"]
|
|
26
|
+
rows = [
|
|
27
|
+
[
|
|
28
|
+
rank,
|
|
29
|
+
h.get("hero_name", h.get("hero_id", "")),
|
|
30
|
+
h.get("hero_id", ""),
|
|
31
|
+
f"{h.get('percentage', 0):.1f}%",
|
|
32
|
+
pct(h.get("win_rate")),
|
|
33
|
+
h.get("count", 0),
|
|
34
|
+
]
|
|
35
|
+
for rank, h in enumerate(data.get("heroes", []), start=1)
|
|
36
|
+
]
|
|
37
|
+
summary = [
|
|
38
|
+
f"[bold]Meta snapshot[/bold] - {format_type}/{source}, last {days}d, "
|
|
39
|
+
f"{data.get('total_matches', 0)} matches"
|
|
40
|
+
]
|
|
41
|
+
emit(payload, tables=[("Heroes", ["#", "Hero", "ID", "Play %", "WR", "Games"], rows)], summary=summary)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def movers(
|
|
46
|
+
days: int = typer.Option(14, "--days", min=1, max=90),
|
|
47
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
48
|
+
source: str = typer.Option("online", "--source"),
|
|
49
|
+
limit: int = typer.Option(10, "--limit", min=1, max=50),
|
|
50
|
+
sort_by: str = typer.Option("popularity", "--sort-by", help="popularity | winrate"),
|
|
51
|
+
json_: bool = typer.Option(False, "--json"),
|
|
52
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
53
|
+
):
|
|
54
|
+
"""Rising and falling heroes over the window."""
|
|
55
|
+
set_mode(json_, csv_)
|
|
56
|
+
payload = client.get(
|
|
57
|
+
"/meta/movers",
|
|
58
|
+
{"days": days, "format": format_type, "source": source, "limit": limit, "sort_by": sort_by},
|
|
59
|
+
)
|
|
60
|
+
arrows = {"up": "↑", "down": "↓", "stable": "→"}
|
|
61
|
+
rows = [
|
|
62
|
+
[
|
|
63
|
+
h.get("hero_name", h.get("hero_id", "")),
|
|
64
|
+
h.get("hero_id", ""),
|
|
65
|
+
arrows.get(h.get("trend_direction", ""), h.get("trend_direction", "")),
|
|
66
|
+
f"{h.get('percent_change', 0):+.1f}%",
|
|
67
|
+
f"{h.get('current_popularity', 0):.1f}%",
|
|
68
|
+
f"{h.get('avg_popularity', 0):.1f}%",
|
|
69
|
+
]
|
|
70
|
+
for h in payload["data"].get("heroes", [])
|
|
71
|
+
]
|
|
72
|
+
emit(
|
|
73
|
+
payload,
|
|
74
|
+
tables=[("Movers", ["Hero", "ID", "Trend", "Change", "Now", "Avg"], rows)],
|
|
75
|
+
summary=[f"[bold]Meta movers[/bold] - {format_type}/{source}, last {days}d, by {sort_by}"],
|
|
76
|
+
)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Tournament events, final standings, decklists."""
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
from .. import client
|
|
5
|
+
from ..output import emit, set_mode
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Paper tournaments: events, standings, decklists")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command("list")
|
|
11
|
+
def list_tournaments(
|
|
12
|
+
format_type: str = typer.Option(None, "--format"),
|
|
13
|
+
tournament_type: str = typer.Option(None, "--type", help="Battle Hardened, National, Pro Tour, Calling..."),
|
|
14
|
+
status: str = typer.Option(None, "--status"),
|
|
15
|
+
has_decklists: bool = typer.Option(None, "--has-decklists/--no-decklists"),
|
|
16
|
+
limit: int = typer.Option(25, "--limit", min=1, max=100),
|
|
17
|
+
offset: int = typer.Option(0, "--offset", min=0),
|
|
18
|
+
json_: bool = typer.Option(False, "--json"),
|
|
19
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
20
|
+
):
|
|
21
|
+
"""List tournaments, newest first."""
|
|
22
|
+
set_mode(json_, csv_)
|
|
23
|
+
payload = client.get(
|
|
24
|
+
"/tournaments",
|
|
25
|
+
{
|
|
26
|
+
"format": format_type,
|
|
27
|
+
"type": tournament_type,
|
|
28
|
+
"status": status,
|
|
29
|
+
"has_decklists": has_decklists,
|
|
30
|
+
"limit": limit,
|
|
31
|
+
"offset": offset,
|
|
32
|
+
},
|
|
33
|
+
)
|
|
34
|
+
rows = [
|
|
35
|
+
[
|
|
36
|
+
e.get("id", ""),
|
|
37
|
+
e.get("tournament_name", ""),
|
|
38
|
+
(e.get("event_date") or "")[:10],
|
|
39
|
+
e.get("format", ""),
|
|
40
|
+
e.get("type", ""),
|
|
41
|
+
e.get("total_players", ""),
|
|
42
|
+
(e.get("winner") or {}).get("player_name", "-") if isinstance(e.get("winner"), dict) else "-",
|
|
43
|
+
]
|
|
44
|
+
for e in payload["data"]
|
|
45
|
+
]
|
|
46
|
+
emit(
|
|
47
|
+
payload,
|
|
48
|
+
tables=[("Tournaments", ["ID", "Name", "Date", "Format", "Type", "Players", "Winner"], rows)],
|
|
49
|
+
summary=[f"{payload['meta'].get('total', len(rows))} tournaments (showing {len(rows)})"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def show(
|
|
55
|
+
event_id: str = typer.Argument(..., help="Tournament id (from `tournaments list`)"),
|
|
56
|
+
json_: bool = typer.Option(False, "--json"),
|
|
57
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
58
|
+
):
|
|
59
|
+
"""Show one tournament's details."""
|
|
60
|
+
set_mode(json_, csv_)
|
|
61
|
+
payload = client.get(f"/tournaments/{event_id}")
|
|
62
|
+
data = payload["data"]
|
|
63
|
+
rows = [
|
|
64
|
+
[field, str(data.get(field, ""))]
|
|
65
|
+
for field in (
|
|
66
|
+
"tournament_name", "event_date", "format", "type", "status",
|
|
67
|
+
"total_players", "total_rounds", "has_decklists", "has_matches", "event_url",
|
|
68
|
+
)
|
|
69
|
+
if data.get(field) is not None
|
|
70
|
+
]
|
|
71
|
+
emit(payload, tables=[(data.get("tournament_name", event_id), ["Field", "Value"], rows)])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command()
|
|
75
|
+
def standings(
|
|
76
|
+
event_id: str = typer.Argument(...),
|
|
77
|
+
json_: bool = typer.Option(False, "--json"),
|
|
78
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
79
|
+
):
|
|
80
|
+
"""Winner + top 8 from official final standings."""
|
|
81
|
+
set_mode(json_, csv_)
|
|
82
|
+
payload = client.get(f"/tournaments/{event_id}/standings")
|
|
83
|
+
data = payload["data"]
|
|
84
|
+
rows = [
|
|
85
|
+
[p.get("rank", ""), p.get("player_name", ""), p.get("hero_name", ""), p.get("hero_id", "")]
|
|
86
|
+
for p in data.get("top_8", [])
|
|
87
|
+
]
|
|
88
|
+
winner = data.get("winner") or {}
|
|
89
|
+
summary = [f"[bold]{data.get('tournament_name', '')}[/bold]"]
|
|
90
|
+
if winner:
|
|
91
|
+
summary.append(
|
|
92
|
+
f"Winner: [green]{winner.get('player_name', '?')}[/green] "
|
|
93
|
+
f"({winner.get('hero_name', '?')})"
|
|
94
|
+
)
|
|
95
|
+
emit(payload, tables=[("Top 8", ["Rank", "Player", "Hero", "Hero ID"], rows)], summary=summary)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def decklists(
|
|
100
|
+
event_id: str = typer.Argument(...),
|
|
101
|
+
json_: bool = typer.Option(False, "--json"),
|
|
102
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
103
|
+
):
|
|
104
|
+
"""Submitted decklists for a tournament."""
|
|
105
|
+
set_mode(json_, csv_)
|
|
106
|
+
payload = client.get(f"/tournaments/{event_id}/decklists")
|
|
107
|
+
rows = [
|
|
108
|
+
[
|
|
109
|
+
d.get("player_id", ""),
|
|
110
|
+
len(d.get("card_ids") or []),
|
|
111
|
+
len(d.get("equipment_ids") or []),
|
|
112
|
+
d.get("decklist_url", ""),
|
|
113
|
+
]
|
|
114
|
+
for d in payload["data"]
|
|
115
|
+
]
|
|
116
|
+
emit(
|
|
117
|
+
payload,
|
|
118
|
+
tables=[("Decklists", ["Player", "Cards", "Equipment", "URL"], rows)],
|
|
119
|
+
summary=[f"{payload['meta'].get('count', len(rows))} decklists (full card lists in --json)"],
|
|
120
|
+
)
|
fablazing_cli/config.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Config + cache paths. Key/URL resolution order: env var, then config file."""
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from platformdirs import user_cache_dir, user_config_dir
|
|
7
|
+
|
|
8
|
+
APP_NAME = "fablazing"
|
|
9
|
+
DEFAULT_API_URL = "https://fablazing.com/api/v1"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def config_path() -> Path:
|
|
13
|
+
return Path(user_config_dir(APP_NAME)) / "config.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_config() -> dict:
|
|
17
|
+
try:
|
|
18
|
+
return json.loads(config_path().read_text(encoding="utf-8"))
|
|
19
|
+
except (OSError, ValueError):
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def save_config(cfg: dict) -> None:
|
|
24
|
+
path = config_path()
|
|
25
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def api_key():
|
|
30
|
+
return os.environ.get("FABLAZING_API_KEY") or load_config().get("api_key")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def api_url() -> str:
|
|
34
|
+
url = os.environ.get("FABLAZING_API_URL") or load_config().get("api_url") or DEFAULT_API_URL
|
|
35
|
+
return url.rstrip("/")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cache_path(name: str) -> Path:
|
|
39
|
+
return Path(user_cache_dir(APP_NAME)) / name
|
fablazing_cli/main.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Fablazing CLI entry point. `fablazing` and `fabz` both land here."""
|
|
2
|
+
import typer
|
|
3
|
+
|
|
4
|
+
from . import __version__
|
|
5
|
+
from .commands import auth, card, hero, meta, tournaments
|
|
6
|
+
from .commands.matchup import matchup
|
|
7
|
+
from .output import console
|
|
8
|
+
from .reference import REFERENCE
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="fablazing",
|
|
12
|
+
help=(
|
|
13
|
+
"Flesh and Blood analytics from your terminal: matchups, meta, cards, "
|
|
14
|
+
"and tournaments. Run `fablazing reference` for the full primer."
|
|
15
|
+
),
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app.add_typer(auth.app, name="auth")
|
|
21
|
+
app.add_typer(hero.app, name="hero")
|
|
22
|
+
app.add_typer(meta.app, name="meta")
|
|
23
|
+
app.add_typer(card.app, name="card")
|
|
24
|
+
app.add_typer(tournaments.app, name="tournaments")
|
|
25
|
+
app.command()(matchup)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@app.command()
|
|
29
|
+
def reference():
|
|
30
|
+
"""Print the full CLI + data-semantics primer (markdown)."""
|
|
31
|
+
print(REFERENCE)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def version():
|
|
36
|
+
"""Print the CLI version."""
|
|
37
|
+
console.print(f"fablazing-cli {__version__}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
if __name__ == "__main__":
|
|
41
|
+
app()
|
fablazing_cli/output.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Rendering: rich tables on a tty, JSON when piped or with --json, CSV of
|
|
2
|
+
the primary table with --csv. JSON mode always gets the full API payload."""
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
# Windows pipes default to cp1252, which crashes on non-ASCII card/hero
|
|
14
|
+
# names (e.g. "Jarl Vetreiði"). Force UTF-8 on both streams.
|
|
15
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
16
|
+
if hasattr(_stream, "reconfigure"):
|
|
17
|
+
try:
|
|
18
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
19
|
+
except (OSError, ValueError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
err_console = Console(stderr=True)
|
|
24
|
+
|
|
25
|
+
_forced_mode = None # None = auto (table on TTY, json otherwise)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_mode(json_flag: bool, csv_flag: bool) -> None:
|
|
29
|
+
global _forced_mode
|
|
30
|
+
if json_flag and csv_flag:
|
|
31
|
+
fail("--json and --csv are mutually exclusive", 2)
|
|
32
|
+
if json_flag:
|
|
33
|
+
_forced_mode = "json"
|
|
34
|
+
elif csv_flag:
|
|
35
|
+
_forced_mode = "csv"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mode() -> str:
|
|
39
|
+
if _forced_mode:
|
|
40
|
+
return _forced_mode
|
|
41
|
+
env_mode = os.environ.get("FABLAZING_OUTPUT")
|
|
42
|
+
if env_mode in ("table", "json", "csv"):
|
|
43
|
+
return env_mode
|
|
44
|
+
return "table" if sys.stdout.isatty() else "json"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def fail(message: str, code: int = 1):
|
|
48
|
+
err_console.print(f"[red]error:[/red] {message}")
|
|
49
|
+
raise typer.Exit(code)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def pct(value, digits: int = 1) -> str:
|
|
53
|
+
"""Format a 0-1 rate as a percentage string."""
|
|
54
|
+
if value is None:
|
|
55
|
+
return "-"
|
|
56
|
+
return f"{value * 100:.{digits}f}%"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def num(value) -> str:
|
|
60
|
+
return "-" if value is None else str(value)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def emit(payload, tables=None, summary=None) -> None:
|
|
64
|
+
"""payload: full API response (JSON mode). tables: [(title, columns,
|
|
65
|
+
rows)]. summary: list of rich-markup lines printed above tables."""
|
|
66
|
+
current = mode()
|
|
67
|
+
|
|
68
|
+
if current == "json":
|
|
69
|
+
buffer = io.StringIO()
|
|
70
|
+
json.dump(payload, buffer, indent=2, default=str, ensure_ascii=False)
|
|
71
|
+
print(buffer.getvalue())
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
if current == "csv":
|
|
75
|
+
if not tables:
|
|
76
|
+
fail("this command has no tabular form; use --json", 2)
|
|
77
|
+
title, columns, rows = tables[0]
|
|
78
|
+
if len(tables) > 1:
|
|
79
|
+
err_console.print(
|
|
80
|
+
f"[yellow]note:[/yellow] --csv only emits the first table ({title}), use --json for everything"
|
|
81
|
+
)
|
|
82
|
+
writer = csv.writer(sys.stdout, lineterminator="\n")
|
|
83
|
+
writer.writerow(columns)
|
|
84
|
+
writer.writerows(rows)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
for line in summary or []:
|
|
88
|
+
console.print(line)
|
|
89
|
+
for title, columns, rows in tables or []:
|
|
90
|
+
table = Table(title=title, title_justify="left")
|
|
91
|
+
for column in columns:
|
|
92
|
+
table.add_column(str(column))
|
|
93
|
+
for row in rows:
|
|
94
|
+
table.add_row(*[str(cell) for cell in row])
|
|
95
|
+
console.print(table)
|
|
96
|
+
if not tables and not summary:
|
|
97
|
+
console.print_json(json.dumps(payload, default=str))
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Text for the `reference` command."""
|
|
2
|
+
|
|
3
|
+
REFERENCE = """\
|
|
4
|
+
# Fablazing CLI reference
|
|
5
|
+
|
|
6
|
+
Terminal client for the Fablazing API: matchup stats, meta breakdowns, card
|
|
7
|
+
analytics and paper tournament results for competitive Flesh and Blood.
|
|
8
|
+
`fablazing` and `fabz` are the same binary.
|
|
9
|
+
|
|
10
|
+
## Auth
|
|
11
|
+
- `fablazing auth set-key fbl_live_...` stores your key, or set the
|
|
12
|
+
FABLAZING_API_KEY env var.
|
|
13
|
+
- `fablazing auth status` checks it.
|
|
14
|
+
- Keys have a daily quota and a per-minute limit. Hitting either exits
|
|
15
|
+
with code 3.
|
|
16
|
+
|
|
17
|
+
## IDs and formats
|
|
18
|
+
- Hero ids look like `EVO001` (set code + number).
|
|
19
|
+
- Card ids are snake_case with a pitch color suffix: `sink_below_red`.
|
|
20
|
+
The same card name can exist as red, yellow and blue. Those are
|
|
21
|
+
different cards and are counted separately everywhere.
|
|
22
|
+
- Names are accepted anywhere an id is. Ambiguous names error out and
|
|
23
|
+
list the matching ids (exit code 2).
|
|
24
|
+
- Formats: `cc` (default), `blitz`, `ll`, `sage`, `gage`.
|
|
25
|
+
|
|
26
|
+
## Data notes
|
|
27
|
+
- win_rate fields are fractions between 0 and 1.
|
|
28
|
+
- In `matchup HERO1 HERO2`, deck1 is HERO1, so deck1_win_rate is HERO1's.
|
|
29
|
+
- going_first means that player took the first turn.
|
|
30
|
+
- Matches are deduplicated. Transforming heroes (Levia, Marionette, Maxx,
|
|
31
|
+
Teklovossen) are stored under their base hero id, with transform stats
|
|
32
|
+
in deck1_transform / deck2_transform.
|
|
33
|
+
- Tech cards are Wilson-scored against the matchup baseline, so a lucky
|
|
34
|
+
streak on a small sample doesn't make the list.
|
|
35
|
+
- Tournament standings and decklists come from official paper events.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
- `fablazing matchup EVO001 "Jarl" --days 30`
|
|
39
|
+
Card filters: `--include1/--include2/--exclude1/--exclude2`, repeatable.
|
|
40
|
+
Copy counts on includes: `--include1 "sink_below_red:gte2"` (eq/gte/lte,
|
|
41
|
+
cards only, not equipment). `--no-dynamics` skips game length stats.
|
|
42
|
+
- `fablazing meta snapshot --format cc --days 14`
|
|
43
|
+
- `fablazing meta movers --days 14`
|
|
44
|
+
- `fablazing hero list` / `hero show EVO001`
|
|
45
|
+
- `fablazing hero aggregate EVO001 --period 14d --source online`
|
|
46
|
+
- `fablazing card search "sink"` / `card show sink_below_red`
|
|
47
|
+
- `fablazing card trends sink_below_red --days 30`
|
|
48
|
+
- `fablazing card synergies sink_below_red`
|
|
49
|
+
- `fablazing card popular --days 7`
|
|
50
|
+
- `fablazing tournaments list --type Calling`
|
|
51
|
+
- `fablazing tournaments show <id>` / `standings <id>` / `decklists <id>`
|
|
52
|
+
- `fablazing reference` prints this text.
|
|
53
|
+
|
|
54
|
+
## Output
|
|
55
|
+
- Tables on a terminal, JSON when piped or with --json (the JSON is the
|
|
56
|
+
full API payload, a superset of the tables), CSV of the primary table
|
|
57
|
+
with --csv. Data goes to stdout, errors to stderr.
|
|
58
|
+
|
|
59
|
+
## Exit codes
|
|
60
|
+
0 ok, 1 api error, 2 usage error, 3 quota exceeded
|
|
61
|
+
"""
|
fablazing_cli/resolve.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Resolve hero/card names to ids so commands accept either form.
|
|
2
|
+
|
|
3
|
+
Heroes come from a 24h disk cache of /heroes. Cards go through
|
|
4
|
+
/cards/search, but only when the input isn't already a card_id.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from . import client
|
|
11
|
+
from .config import cache_path
|
|
12
|
+
from .output import fail
|
|
13
|
+
|
|
14
|
+
HERO_ID_RE = re.compile(r"^[A-Z]{2,4}\d{3}$")
|
|
15
|
+
CARD_ID_RE = re.compile(r"^[a-z0-9_]+$")
|
|
16
|
+
CARD_SPEC_RE = re.compile(r"^(?P<card>.+?):(?P<spec>(eq|gte|lte)\d+)$")
|
|
17
|
+
HERO_CACHE_TTL = 24 * 3600
|
|
18
|
+
_HEROES_CACHE_FILE = "heroes.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def hero_list() -> list:
|
|
22
|
+
path = cache_path(_HEROES_CACHE_FILE)
|
|
23
|
+
try:
|
|
24
|
+
cached = json.loads(path.read_text(encoding="utf-8"))
|
|
25
|
+
if time.time() - cached["at"] < HERO_CACHE_TTL:
|
|
26
|
+
return cached["heroes"]
|
|
27
|
+
except (OSError, ValueError, KeyError):
|
|
28
|
+
pass
|
|
29
|
+
heroes = client.get("/heroes")["data"]
|
|
30
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
path.write_text(
|
|
32
|
+
json.dumps({"at": time.time(), "heroes": heroes}), encoding="utf-8"
|
|
33
|
+
)
|
|
34
|
+
return heroes
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def hero_names() -> dict:
|
|
38
|
+
"""hero_id -> hero_name map from the cached hero list."""
|
|
39
|
+
return {h["hero_id"]: h.get("hero_name", h["hero_id"]) for h in hero_list()}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def hero_id(text: str) -> str:
|
|
43
|
+
candidate = text.strip()
|
|
44
|
+
if HERO_ID_RE.match(candidate.upper()):
|
|
45
|
+
return candidate.upper()
|
|
46
|
+
|
|
47
|
+
query = candidate.lower()
|
|
48
|
+
heroes = hero_list()
|
|
49
|
+
exact = [
|
|
50
|
+
h for h in heroes
|
|
51
|
+
if h.get("hero_name", "").lower() == query or h.get("snake_case") == query
|
|
52
|
+
]
|
|
53
|
+
if len(exact) == 1:
|
|
54
|
+
return exact[0]["hero_id"]
|
|
55
|
+
partial = [h for h in heroes if query in h.get("hero_name", "").lower()]
|
|
56
|
+
if len(partial) == 1:
|
|
57
|
+
return partial[0]["hero_id"]
|
|
58
|
+
if not partial:
|
|
59
|
+
fail(f"no hero matches '{text}'", 2)
|
|
60
|
+
options = ", ".join(f"{h['hero_name']} ({h['hero_id']})" for h in partial[:8])
|
|
61
|
+
fail(f"ambiguous hero '{text}' - did you mean: {options}", 2)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def card_id(text: str, allow_spec: bool = False) -> str:
|
|
65
|
+
"""Resolve a card name or id; optionally preserve a :gte2-style suffix."""
|
|
66
|
+
candidate = text.strip()
|
|
67
|
+
spec = ""
|
|
68
|
+
match = CARD_SPEC_RE.match(candidate)
|
|
69
|
+
if match and allow_spec:
|
|
70
|
+
candidate, spec = match.group("card"), ":" + match.group("spec")
|
|
71
|
+
|
|
72
|
+
if CARD_ID_RE.match(candidate):
|
|
73
|
+
return candidate + spec
|
|
74
|
+
|
|
75
|
+
results = client.get("/cards/search", {"q": candidate, "limit": 10})["data"]
|
|
76
|
+
exact = [c for c in results if c.get("card_name", "").lower() == candidate.lower()]
|
|
77
|
+
pool = exact or results
|
|
78
|
+
if len(pool) == 1:
|
|
79
|
+
return pool[0]["card_id"] + spec
|
|
80
|
+
if not pool:
|
|
81
|
+
fail(f"no card matches '{candidate}'", 2)
|
|
82
|
+
options = ", ".join(c["card_id"] for c in pool[:9])
|
|
83
|
+
fail(
|
|
84
|
+
f"ambiguous card '{candidate}' (pitch variants share names) - "
|
|
85
|
+
f"use a card_id: {options}",
|
|
86
|
+
2,
|
|
87
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fablazing-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fablazing analytics from your terminal - Flesh and Blood matchups, meta, cards, and tournaments.
|
|
5
|
+
Project-URL: Homepage, https://fablazing.com
|
|
6
|
+
Author: Fablazing
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: analytics,cli,fab,flesh-and-blood,tcg
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: httpx>=0.27
|
|
11
|
+
Requires-Dist: platformdirs>=4.0
|
|
12
|
+
Requires-Dist: rich>=13.0
|
|
13
|
+
Requires-Dist: typer>=0.12
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# fablazing-cli
|
|
17
|
+
|
|
18
|
+
Terminal client for [Fablazing](https://fablazing.com): matchup stats, meta
|
|
19
|
+
breakdowns, card trends and paper tournament results for Flesh and Blood.
|
|
20
|
+
|
|
21
|
+
You need an API key (Builder plan).
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
pipx install fablazing-cli
|
|
26
|
+
|
|
27
|
+
or plain `pip install fablazing-cli`. On Windows without pipx:
|
|
28
|
+
|
|
29
|
+
py -m pip install --user pipx
|
|
30
|
+
py -m pipx ensurepath
|
|
31
|
+
|
|
32
|
+
This installs `fablazing` and `fabz`, same thing.
|
|
33
|
+
|
|
34
|
+
## Setup
|
|
35
|
+
|
|
36
|
+
fabz auth set-key fbl_live_xxx
|
|
37
|
+
fabz auth status
|
|
38
|
+
|
|
39
|
+
The key can also come from the `FABLAZING_API_KEY` env var.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
fabz matchup "Dash I/O" Jarl --days 30
|
|
44
|
+
fabz meta snapshot
|
|
45
|
+
fabz meta movers
|
|
46
|
+
fabz hero aggregate EVO001 --period 14d
|
|
47
|
+
fabz card trends "Sink Below" --json
|
|
48
|
+
fabz card popular --days 7 --csv > popular.csv
|
|
49
|
+
fabz tournaments list --type Calling
|
|
50
|
+
|
|
51
|
+
Heroes and cards can be given by name or id. A name that matches several
|
|
52
|
+
cards (pitch variants share names) errors and lists the ids so you can pick
|
|
53
|
+
one. Copy count filters work on matchup includes:
|
|
54
|
+
`--include1 "sink_below_red:gte2"`.
|
|
55
|
+
|
|
56
|
+
## Output
|
|
57
|
+
|
|
58
|
+
Tables on a terminal. JSON when piped or with `--json`, CSV with `--csv`.
|
|
59
|
+
Exit codes: 0 ok, 1 api error, 2 usage error, 3 quota exceeded.
|
|
60
|
+
|
|
61
|
+
## Tab completion
|
|
62
|
+
|
|
63
|
+
fablazing --install-completion
|
|
64
|
+
|
|
65
|
+
## Reference
|
|
66
|
+
|
|
67
|
+
`fablazing reference` prints the full command and data reference.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
fablazing_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
fablazing_cli/client.py,sha256=QcX7KPVMVJTY9-eIebv_SPPaoEnZLTwNUWXAiGXZzD8,1500
|
|
3
|
+
fablazing_cli/config.py,sha256=fWMBakRegMF2VxCOdp56Zb7fJUHZzVsMEoaxgFYAs8s,1015
|
|
4
|
+
fablazing_cli/main.py,sha256=c2YVSmsHrVsHfSQb88l3FhGJs9j8gDlqvMAjf_7m5w8,1070
|
|
5
|
+
fablazing_cli/output.py,sha256=pclbVso68F-JJAtImDC8ed-M8qqAlO1KYMLtoQRt0-E,2968
|
|
6
|
+
fablazing_cli/reference.py,sha256=8Gm7UEJpJreeHVgCiFyWDy6wvTQ0Pz4zWShnOhnF2tg,2617
|
|
7
|
+
fablazing_cli/resolve.py,sha256=dwlbGOADRNakW4tYTbxQ6o6QTvYQ5chBkadOUPpmJBs,2859
|
|
8
|
+
fablazing_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
fablazing_cli/commands/auth.py,sha256=KgDNwnqxgtxU1zgKAsvY4PgkRvU8xS4zg9LVqgVfuS4,1451
|
|
10
|
+
fablazing_cli/commands/card.py,sha256=4FIqeOqDu-OYKiKab2vhFW8hw_gNze0F1xgXC_m-TqU,5052
|
|
11
|
+
fablazing_cli/commands/hero.py,sha256=SgZ0JZeZFphPGSYg4VTpnTikN4dSgk8VjaLJ74RCB40,3214
|
|
12
|
+
fablazing_cli/commands/matchup.py,sha256=5i5iapR0QRcQ54fBEwQYfvP5cg0EBBKmAQi0UGl66O8,5346
|
|
13
|
+
fablazing_cli/commands/meta.py,sha256=Po-m64OSMKUpo2i0st4N_TyCiAu9pmpzA-mAZ8U_JuM,2821
|
|
14
|
+
fablazing_cli/commands/tournaments.py,sha256=UqYtWrYdFZK3hfVln8PBLvMLNit3rosyGA8Q-McZtOw,4127
|
|
15
|
+
fablazing_cli-0.1.0.dist-info/METADATA,sha256=WEx4AM92FYdYaH1YO0SAGsAkl66UYUus8km-jRvTfKA,1814
|
|
16
|
+
fablazing_cli-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
17
|
+
fablazing_cli-0.1.0.dist-info/entry_points.txt,sha256=w6VNYUimCKbqnOw-aT8JA2q0DxE-dQTZBfh40UL2vyc,83
|
|
18
|
+
fablazing_cli-0.1.0.dist-info/RECORD,,
|