fablazing-cli 0.2.2__tar.gz → 0.3.0__tar.gz
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-0.2.2 → fablazing_cli-0.3.0}/CHANGELOG.md +10 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/PKG-INFO +1 -1
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/pyproject.toml +1 -1
- fablazing_cli-0.3.0/src/fablazing_cli/__init__.py +1 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/card.py +66 -16
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/hero.py +44 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/matchup.py +5 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/meta.py +6 -6
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/tournaments.py +13 -13
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/main.py +1 -2
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/reference.py +10 -0
- fablazing_cli-0.2.2/src/fablazing_cli/__init__.py +0 -1
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/.gitignore +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/README.md +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/client.py +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/__init__.py +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/auth.py +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/config.py +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/output.py +0 -0
- {fablazing_cli-0.2.2 → fablazing_cli-0.3.0}/src/fablazing_cli/resolve.py +0 -0
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
- `fabz hero cards`: most-used deck cards for a hero vs the whole field
|
|
6
|
+
(frequency, win rate, Wilson lower bound, copies) - new API endpoint
|
|
7
|
+
- `fabz card pov`: a card's win-rate impact for a hero, overall and per
|
|
8
|
+
opponent - new API endpoint
|
|
9
|
+
- 95% confidence intervals on matchup and hero-cards win rates
|
|
10
|
+
- Tabular API endpoints accept ?output=csv (+ ?api_key= for spreadsheet
|
|
11
|
+
IMPORTDATA use)
|
|
12
|
+
|
|
3
13
|
## 0.2.2
|
|
4
14
|
|
|
5
15
|
- Reference: name the exact `meets_threshold` field on tech candidates and
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "fablazing-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Fablazing analytics from your terminal - Flesh and Blood matchups, meta, cards, and tournaments."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""card search / show / trends / synergies / popular"""
|
|
2
2
|
import typer
|
|
3
3
|
|
|
4
4
|
from .. import client, resolve
|
|
@@ -11,11 +11,11 @@ app = typer.Typer(help="Card catalog and analytics")
|
|
|
11
11
|
def search(
|
|
12
12
|
query: str = typer.Argument(..., help="Part of a card name"),
|
|
13
13
|
limit: int = typer.Option(25, "--limit", min=1, max=100),
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
15
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
16
16
|
):
|
|
17
17
|
"""Search cards and equipment by name."""
|
|
18
|
-
set_mode(
|
|
18
|
+
set_mode(as_json, as_csv)
|
|
19
19
|
payload = client.get("/cards/search", {"q": query, "limit": limit})
|
|
20
20
|
rows = [
|
|
21
21
|
[
|
|
@@ -34,11 +34,11 @@ def search(
|
|
|
34
34
|
@app.command()
|
|
35
35
|
def show(
|
|
36
36
|
card: str = typer.Argument(..., help="Card id or name"),
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
38
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
39
39
|
):
|
|
40
40
|
"""Show one card's metadata."""
|
|
41
|
-
set_mode(
|
|
41
|
+
set_mode(as_json, as_csv)
|
|
42
42
|
card_id = resolve.card_id(card)
|
|
43
43
|
payload = client.get(f"/cards/{card_id}")
|
|
44
44
|
data = payload["data"]
|
|
@@ -60,11 +60,11 @@ def trends(
|
|
|
60
60
|
card: str = typer.Argument(..., help="Card id or name"),
|
|
61
61
|
format_type: str = typer.Option("cc", "--format"),
|
|
62
62
|
days: int = typer.Option(30, "--days", min=1, max=365),
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
64
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
65
65
|
):
|
|
66
66
|
"""Daily play-rate time series."""
|
|
67
|
-
set_mode(
|
|
67
|
+
set_mode(as_json, as_csv)
|
|
68
68
|
card_id = resolve.card_id(card)
|
|
69
69
|
payload = client.get(f"/cards/{card_id}/trends", {"format": format_type, "days": days})
|
|
70
70
|
data = payload["data"]
|
|
@@ -92,11 +92,11 @@ def synergies(
|
|
|
92
92
|
card: str = typer.Argument(..., help="Card id or name"),
|
|
93
93
|
format_type: str = typer.Option("cc", "--format"),
|
|
94
94
|
limit: int = typer.Option(20, "--limit", min=1, max=50),
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
96
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
97
97
|
):
|
|
98
98
|
"""Co-occurrence synergy partners, with scores."""
|
|
99
|
-
set_mode(
|
|
99
|
+
set_mode(as_json, as_csv)
|
|
100
100
|
card_id = resolve.card_id(card)
|
|
101
101
|
payload = client.get(f"/cards/{card_id}/synergies", {"format": format_type, "limit": limit})
|
|
102
102
|
rows = [
|
|
@@ -112,16 +112,66 @@ def synergies(
|
|
|
112
112
|
emit(payload, tables=[(f"Synergies for {card_id}", ["ID", "Name", "Score", "Co-occur", "Price"], rows)])
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
@app.command()
|
|
116
|
+
def pov(
|
|
117
|
+
hero: str = typer.Argument(..., help="Hero id or name", autocompletion=resolve.complete_hero),
|
|
118
|
+
card: str = typer.Argument(..., help="Card id or name"),
|
|
119
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
120
|
+
days: int = typer.Option(30, "--days", min=1, max=90),
|
|
121
|
+
top: int = typer.Option(12, "--top", min=1, max=50, help="Matchup rows"),
|
|
122
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
123
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
124
|
+
):
|
|
125
|
+
"""Card point of view: does running this card change the hero's win
|
|
126
|
+
rate, overall and against which opponents."""
|
|
127
|
+
set_mode(as_json, as_csv)
|
|
128
|
+
hero_id = resolve.hero_id(hero)
|
|
129
|
+
card_id = resolve.card_id(card)
|
|
130
|
+
payload = client.get(
|
|
131
|
+
f"/card-pov/{hero_id}/{card_id}", {"format": format_type, "days": days}
|
|
132
|
+
)
|
|
133
|
+
data = payload["data"]
|
|
134
|
+
overall = data.get("overall_card_impact") or {}
|
|
135
|
+
summary = [
|
|
136
|
+
f"[bold]{card_id}[/bold] on [bold]{hero_id}[/bold] - {format_type}, {days}d, "
|
|
137
|
+
f"{data.get('total_matches_analyzed', overall.get('total_matches', '?'))} matches"
|
|
138
|
+
]
|
|
139
|
+
if overall:
|
|
140
|
+
summary.append(
|
|
141
|
+
f"presence {pct(overall.get('presence_rate'))} | "
|
|
142
|
+
f"WR with {pct(overall.get('card_win_rate'))} vs without {pct(overall.get('without_card_win_rate'))} "
|
|
143
|
+
f"(impact {overall.get('presence_impact', 0) * 100:+.1f}%)"
|
|
144
|
+
)
|
|
145
|
+
impacts = data.get("matchup_impacts") or []
|
|
146
|
+
rows = [
|
|
147
|
+
[
|
|
148
|
+
m.get("opponent_hero_name", m.get("opponent_hero_id", "")),
|
|
149
|
+
m.get("total_matches", ""),
|
|
150
|
+
pct(m.get("card_presence_rate")),
|
|
151
|
+
pct(m.get("base_win_rate")),
|
|
152
|
+
pct(m.get("card_win_rate")),
|
|
153
|
+
pct(m.get("without_card_win_rate")),
|
|
154
|
+
f"{m.get('presence_impact', 0) * 100:+.1f}%",
|
|
155
|
+
]
|
|
156
|
+
for m in impacts[:top]
|
|
157
|
+
]
|
|
158
|
+
emit(
|
|
159
|
+
payload,
|
|
160
|
+
tables=[("Per-matchup impact", ["Opponent", "N", "Presence", "Base WR", "With", "Without", "Impact"], rows)],
|
|
161
|
+
summary=summary,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
115
165
|
@app.command()
|
|
116
166
|
def popular(
|
|
117
167
|
format_type: str = typer.Option("cc", "--format"),
|
|
118
168
|
days: int = typer.Option(7, "--days", min=1, max=365),
|
|
119
169
|
limit: int = typer.Option(25, "--limit", min=1, max=100),
|
|
120
|
-
|
|
121
|
-
|
|
170
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
171
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
122
172
|
):
|
|
123
173
|
"""Most-played cards over the window."""
|
|
124
|
-
set_mode(
|
|
174
|
+
set_mode(as_json, as_csv)
|
|
125
175
|
payload = client.get("/cards/popular", {"format": format_type, "days": days, "limit": limit})
|
|
126
176
|
rows = [
|
|
127
177
|
[
|
|
@@ -42,6 +42,50 @@ def show(
|
|
|
42
42
|
emit(payload, tables=[(data.get("hero_name", hero_id), ["Field", "Value"], rows)])
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
@app.command()
|
|
46
|
+
def cards(
|
|
47
|
+
hero: str = typer.Argument(..., help="Hero id or name", autocompletion=resolve.complete_hero),
|
|
48
|
+
format_type: str = typer.Option("cc", "--format"),
|
|
49
|
+
days: int = typer.Option(30, "--days", min=1, max=90),
|
|
50
|
+
min_freq: float = typer.Option(0.02, "--min-freq", help="Minimum deck frequency"),
|
|
51
|
+
limit: int = typer.Option(30, "--limit", min=1, max=300),
|
|
52
|
+
json_: bool = typer.Option(False, "--json"),
|
|
53
|
+
csv_: bool = typer.Option(False, "--csv"),
|
|
54
|
+
):
|
|
55
|
+
"""Most-used deck cards for a hero across the whole field: frequency,
|
|
56
|
+
win rate, Wilson lower bound, average copies."""
|
|
57
|
+
set_mode(json_, csv_)
|
|
58
|
+
hero_id = resolve.hero_id(hero)
|
|
59
|
+
payload = client.get(
|
|
60
|
+
f"/heroes/{hero_id}/cards",
|
|
61
|
+
{"format": format_type, "days": days, "min_frequency": min_freq, "limit": limit},
|
|
62
|
+
)
|
|
63
|
+
data = payload["data"]
|
|
64
|
+
rows = [
|
|
65
|
+
[
|
|
66
|
+
c.get("card_id", ""),
|
|
67
|
+
pct(c.get("frequency")),
|
|
68
|
+
c.get("matches", ""),
|
|
69
|
+
pct(c.get("win_rate")),
|
|
70
|
+
pct(c.get("wilson_lower_bound")),
|
|
71
|
+
f"{c.get('copies_avg', 0):.1f}",
|
|
72
|
+
f"${c['tcg_market_price']:.2f}" if c.get("tcg_market_price") else "-",
|
|
73
|
+
]
|
|
74
|
+
for c in data.get("cards", [])
|
|
75
|
+
]
|
|
76
|
+
ci = data.get("overall_win_rate_ci") or [None, None]
|
|
77
|
+
summary = [
|
|
78
|
+
f"[bold]{resolve.hero_names().get(hero_id, hero_id)}[/bold] vs the field - "
|
|
79
|
+
f"{format_type}, {days}d: {data.get('total_games', 0)} games, "
|
|
80
|
+
f"WR {pct(data.get('overall_win_rate'))} (95% CI {pct(ci[0])}-{pct(ci[1])})"
|
|
81
|
+
]
|
|
82
|
+
emit(
|
|
83
|
+
payload,
|
|
84
|
+
tables=[("Cards", ["Card", "Freq", "N", "WR", "Wilson LB", "Copies", "Price"], rows)],
|
|
85
|
+
summary=summary,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
45
89
|
@app.command()
|
|
46
90
|
def aggregate(
|
|
47
91
|
hero: str = typer.Argument(..., help="Hero id or name", autocompletion=resolve.complete_hero),
|
|
@@ -50,6 +50,11 @@ def matchup(
|
|
|
50
50
|
summary = [
|
|
51
51
|
f"[bold]{name1}[/bold] vs [bold]{name2}[/bold] - {format_type}, {total} matches"
|
|
52
52
|
]
|
|
53
|
+
ci1, ci2 = data.get("deck1_win_rate_ci"), data.get("deck2_win_rate_ci")
|
|
54
|
+
if ci1 and ci2:
|
|
55
|
+
summary.append(
|
|
56
|
+
f"95% CI: {name1} {pct(ci1[0])}-{pct(ci1[1])}, {name2} {pct(ci2[0])}-{pct(ci2[1])}"
|
|
57
|
+
)
|
|
53
58
|
if total == 0:
|
|
54
59
|
emit(payload, summary=summary + ["No matches found for these filters."])
|
|
55
60
|
return
|
|
@@ -58,17 +58,17 @@ def movers(
|
|
|
58
58
|
{"days": days, "format": format_type, "source": source, "limit": limit, "sort_by": sort_by},
|
|
59
59
|
)
|
|
60
60
|
arrows = {"up": "↑", "down": "↓", "stable": "→"}
|
|
61
|
-
rows = [
|
|
62
|
-
|
|
61
|
+
rows = []
|
|
62
|
+
for h in payload["data"].get("heroes", []):
|
|
63
|
+
trend = h.get("trend_direction", "")
|
|
64
|
+
rows.append([
|
|
63
65
|
h.get("hero_name", h.get("hero_id", "")),
|
|
64
66
|
h.get("hero_id", ""),
|
|
65
|
-
arrows.get(
|
|
67
|
+
arrows.get(trend, trend),
|
|
66
68
|
f"{h.get('percent_change', 0):+.1f}%",
|
|
67
69
|
f"{h.get('current_popularity', 0):.1f}%",
|
|
68
70
|
f"{h.get('avg_popularity', 0):.1f}%",
|
|
69
|
-
]
|
|
70
|
-
for h in payload["data"].get("heroes", [])
|
|
71
|
-
]
|
|
71
|
+
])
|
|
72
72
|
emit(
|
|
73
73
|
payload,
|
|
74
74
|
tables=[("Movers", ["Hero", "ID", "Trend", "Change", "Now", "Avg"], rows)],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""tournament events, standings, decklists"""
|
|
2
2
|
import typer
|
|
3
3
|
|
|
4
4
|
from .. import client
|
|
@@ -15,11 +15,11 @@ def list_tournaments(
|
|
|
15
15
|
has_decklists: bool = typer.Option(None, "--has-decklists/--no-decklists"),
|
|
16
16
|
limit: int = typer.Option(25, "--limit", min=1, max=100),
|
|
17
17
|
offset: int = typer.Option(0, "--offset", min=0),
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
19
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
20
20
|
):
|
|
21
21
|
"""List tournaments, newest first."""
|
|
22
|
-
set_mode(
|
|
22
|
+
set_mode(as_json, as_csv)
|
|
23
23
|
payload = client.get(
|
|
24
24
|
"/tournaments",
|
|
25
25
|
{
|
|
@@ -53,11 +53,11 @@ def list_tournaments(
|
|
|
53
53
|
@app.command()
|
|
54
54
|
def show(
|
|
55
55
|
event_id: str = typer.Argument(..., help="Tournament id (from `tournaments list`)"),
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
57
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
58
58
|
):
|
|
59
59
|
"""Show one tournament's details."""
|
|
60
|
-
set_mode(
|
|
60
|
+
set_mode(as_json, as_csv)
|
|
61
61
|
payload = client.get(f"/tournaments/{event_id}")
|
|
62
62
|
data = payload["data"]
|
|
63
63
|
rows = [
|
|
@@ -74,11 +74,11 @@ def show(
|
|
|
74
74
|
@app.command()
|
|
75
75
|
def standings(
|
|
76
76
|
event_id: str = typer.Argument(...),
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
78
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
79
79
|
):
|
|
80
80
|
"""Winner + top 8 from official final standings."""
|
|
81
|
-
set_mode(
|
|
81
|
+
set_mode(as_json, as_csv)
|
|
82
82
|
payload = client.get(f"/tournaments/{event_id}/standings")
|
|
83
83
|
data = payload["data"]
|
|
84
84
|
rows = [
|
|
@@ -98,11 +98,11 @@ def standings(
|
|
|
98
98
|
@app.command()
|
|
99
99
|
def decklists(
|
|
100
100
|
event_id: str = typer.Argument(...),
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
as_json: bool = typer.Option(False, "--json"),
|
|
102
|
+
as_csv: bool = typer.Option(False, "--csv"),
|
|
103
103
|
):
|
|
104
104
|
"""Submitted decklists for a tournament."""
|
|
105
|
-
set_mode(
|
|
105
|
+
set_mode(as_json, as_csv)
|
|
106
106
|
payload = client.get(f"/tournaments/{event_id}/decklists")
|
|
107
107
|
rows = [
|
|
108
108
|
[
|
|
@@ -27,8 +27,7 @@ def _main():
|
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _completion_tip():
|
|
30
|
-
|
|
31
|
-
itself and when not on a terminal."""
|
|
30
|
+
# one-time nudge; skip when running as a shell completer or piped
|
|
32
31
|
if any(k.endswith("_COMPLETE") for k in os.environ):
|
|
33
32
|
return
|
|
34
33
|
cfg = load_config()
|
|
@@ -56,6 +56,11 @@ analytics and paper tournament results for competitive Flesh and Blood.
|
|
|
56
56
|
pairing of the top-N heroes, in one call (never loop matchup for this)
|
|
57
57
|
- `fablazing hero list` / `hero show EVO001`
|
|
58
58
|
- `fablazing hero aggregate EVO001 --period 14d --source online`
|
|
59
|
+
- `fablazing hero cards MON119 --days 30` - most-used deck cards for a
|
|
60
|
+
hero vs the whole field, with per-card WR and Wilson lower bound
|
|
61
|
+
(deck cards only, no equipment; live aggregation cached up to 4h)
|
|
62
|
+
- `fablazing card pov MON119 "Dread Screamer"` - does running the card
|
|
63
|
+
change the hero's win rate, overall and per opponent (cached up to 4h)
|
|
59
64
|
- `fablazing card search "sink"` / `card show sink_below_red`
|
|
60
65
|
- `fablazing card trends sink_below_red --days 30`
|
|
61
66
|
- `fablazing card synergies sink_below_red`
|
|
@@ -68,6 +73,11 @@ analytics and paper tournament results for competitive Flesh and Blood.
|
|
|
68
73
|
- Tables on a terminal, JSON when piped or with --json (the JSON is the
|
|
69
74
|
full API payload, a superset of the tables), CSV of the primary table
|
|
70
75
|
with --csv. Data goes to stdout, errors to stderr.
|
|
76
|
+
- Matchup and hero-cards responses include 95% Wilson confidence
|
|
77
|
+
intervals (`*_win_rate_ci`) next to the win rates.
|
|
78
|
+
- Spreadsheets can skip the CLI: tabular API endpoints accept
|
|
79
|
+
`?output=csv&api_key=fbl_live_...` for Google Sheets IMPORTDATA
|
|
80
|
+
(careful: the key is then visible in the sheet).
|
|
71
81
|
|
|
72
82
|
## Exit codes
|
|
73
83
|
0 ok, 1 api error, 2 usage error, 3 quota exceeded
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|