fablazing-cli 0.2.1__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.
Files changed (20) hide show
  1. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/CHANGELOG.md +15 -0
  2. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/PKG-INFO +1 -1
  3. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/pyproject.toml +1 -1
  4. fablazing_cli-0.3.0/src/fablazing_cli/__init__.py +1 -0
  5. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/card.py +66 -16
  6. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/hero.py +44 -0
  7. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/matchup.py +5 -0
  8. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/meta.py +6 -6
  9. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/tournaments.py +13 -13
  10. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/main.py +1 -2
  11. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/reference.py +17 -5
  12. fablazing_cli-0.2.1/src/fablazing_cli/__init__.py +0 -1
  13. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/.gitignore +0 -0
  14. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/README.md +0 -0
  15. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/client.py +0 -0
  16. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/__init__.py +0 -0
  17. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/commands/auth.py +0 -0
  18. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/config.py +0 -0
  19. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/output.py +0 -0
  20. {fablazing_cli-0.2.1 → fablazing_cli-0.3.0}/src/fablazing_cli/resolve.py +0 -0
@@ -1,5 +1,20 @@
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
+
13
+ ## 0.2.2
14
+
15
+ - Reference: name the exact `meets_threshold` field on tech candidates and
16
+ document `data_through` on matchup responses (newest ingested match)
17
+
3
18
  ## 0.2.1
4
19
 
5
20
  - Matchup: when no card clears the tech-card significance bar (common in
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fablazing-cli
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Fablazing analytics from your terminal - Flesh and Blood matchups, meta, cards, and tournaments.
5
5
  Project-URL: Homepage, https://fablazing.com
6
6
  Author: Fablazing
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "fablazing-cli"
7
- version = "0.2.1"
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
- """Card search, metadata, trends, synergies, popularity."""
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
- json_: bool = typer.Option(False, "--json"),
15
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
38
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
64
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
96
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
121
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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(h.get("trend_direction", ""), h.get("trend_direction", "")),
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
- """Tournament events, final standings, decklists."""
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
- json_: bool = typer.Option(False, "--json"),
19
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
57
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
78
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- json_: bool = typer.Option(False, "--json"),
102
- csv_: bool = typer.Option(False, "--csv"),
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(json_, csv_)
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
- """One-time hint about tab completion. Skipped during shell completion
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()
@@ -34,14 +34,16 @@ analytics and paper tournament results for competitive Flesh and Blood.
34
34
  in deck1_transform / deck2_transform.
35
35
  - Tech cards are Wilson-scored against the matchup baseline, so a lucky
36
36
  streak on a small sample doesn't make the list. In close matchups no
37
- card may clear the bar - then `tech_candidates` lists the closest
38
- near-misses with their scores, explicitly marked below-threshold.
37
+ card may clear the bar - then `deck{1,2}_tech_candidates` lists the
38
+ closest near-misses with their scores; each entry carries
39
+ `meets_threshold: false` (that field, exactly) and the response
40
+ includes a `deck{1,2}_tech_note` explaining the situation.
39
41
  - Tournament standings and decklists come from official paper events.
40
42
  - Freshness: `matchup` queries live match data (ingested every ~5
41
43
  minutes). Meta snapshot/movers and card trends/popular are daily
42
- aggregates (previous day processed at 03:00 / 02:00 UTC) - their
43
- responses carry `data_through` in meta so you can see exactly how
44
- fresh the numbers are.
44
+ aggregates (previous day processed at 03:00 / 02:00 UTC). Every one of
45
+ these responses carries `data_through` in meta - for matchup it is the
46
+ newest ingested match, for aggregates the latest processed day.
45
47
 
46
48
  ## Commands
47
49
  - `fablazing matchup EVO001 "Jarl" --days 30`
@@ -54,6 +56,11 @@ analytics and paper tournament results for competitive Flesh and Blood.
54
56
  pairing of the top-N heroes, in one call (never loop matchup for this)
55
57
  - `fablazing hero list` / `hero show EVO001`
56
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)
57
64
  - `fablazing card search "sink"` / `card show sink_below_red`
58
65
  - `fablazing card trends sink_below_red --days 30`
59
66
  - `fablazing card synergies sink_below_red`
@@ -66,6 +73,11 @@ analytics and paper tournament results for competitive Flesh and Blood.
66
73
  - Tables on a terminal, JSON when piped or with --json (the JSON is the
67
74
  full API payload, a superset of the tables), CSV of the primary table
68
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).
69
81
 
70
82
  ## Exit codes
71
83
  0 ok, 1 api error, 2 usage error, 3 quota exceeded
@@ -1 +0,0 @@
1
- __version__ = "0.2.1"
File without changes
File without changes