Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ TCGdex SDK ──[ sync, once ]──▶ local JSON cache
```text
tcgp-deck-genie sync # download the TCGP corpus (one-time, ~30-90 s)
tcgp-deck-genie sync-missions # download solo-battle opponent decks (one-time)
tcgp-deck-genie info # print cache summary
tcgp-deck-genie info # print card corpus and mission cache summaries
tcgp-deck-genie search # filter the local corpus, no API cost
tcgp-deck-genie missions # list/search/show solo-battle decks, no API cost
tcgp-deck-genie build-deck # produce a 20-card deck via Gemini
Expand Down
2 changes: 0 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ authors = [{ name = "Michael Murphy" }]
keywords = ["pokemon", "tcg", "tcgp", "gemini", "deck-builder", "llm"]
classifiers = [
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Games/Entertainment",
]
Expand Down
81 changes: 61 additions & 20 deletions src/tcgp_deck_genie/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from rich.table import Table

from . import __version__
from .cache import Corpus, corpus_info, default_cache_dir, load_corpus, save_corpus
from .cache import Corpus, corpus_info, corpus_path, default_cache_dir, load_corpus, save_corpus
from .deck_builder import (
BuildOptions,
BuildResult,
Expand All @@ -41,6 +41,8 @@
MissionLookupError,
find_mission,
load_missions,
missions_info,
missions_path,
save_missions,
)
from .models import ENERGY_TYPES, Card, DeckEntry, DeckPlan, OpponentDeckSpec
Expand Down Expand Up @@ -168,21 +170,10 @@ def cb(p: FetchProgress, _task=task_id) -> None:
# ---------------------------------------------------------------------------


@main.command()
@click.pass_context
def info(ctx: click.Context) -> None:
"""Show what's in the local cache."""
cache_dir: Path = ctx.obj["cache_dir"]
summary = corpus_info(cache_dir)
if summary is None:
console.print(
f"[yellow]No cache found at {cache_dir / 'cards_tcgp.json'}[/].\n"
"Run [bold]tcgp-deck-genie sync[/] first."
)
raise SystemExit(1)
table = Table(title="Cache summary", show_header=False)
def _print_cache_section(title: str, summary: dict, count_label: str, count_key: str) -> None:
table = Table(title=title, show_header=False)
table.add_row("Path", str(summary["path"]))
table.add_row("Cards", str(summary["card_count"]))
table.add_row(count_label, str(summary[count_key]))
table.add_row("Sets", ", ".join(summary["sets_included"]))
table.add_row(
"Fetched at",
Expand All @@ -192,6 +183,36 @@ def info(ctx: click.Context) -> None:
console.print(table)


@main.command()
@click.pass_context
def info(ctx: click.Context) -> None:
"""Show what's in the local card and mission caches."""
cache_dir: Path = ctx.obj["cache_dir"]
card_summary = corpus_info(cache_dir)
mission_summary = missions_info(cache_dir)

if card_summary:
_print_cache_section("Card corpus", card_summary, "Cards", "card_count")
else:
console.print(
f"[yellow]No card cache found at {corpus_path(cache_dir)}[/].\n"
"Run [bold]tcgp-deck-genie sync[/] first."
)

console.print()

if mission_summary:
_print_cache_section("Mission decks", mission_summary, "Decks", "deck_count")
else:
console.print(
f"[yellow]No mission cache found at {missions_path(cache_dir)}[/].\n"
"Run [bold]tcgp-deck-genie sync-missions[/] first."
)

if card_summary is None:
raise SystemExit(1)


# ---------------------------------------------------------------------------
# sync-missions
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -292,7 +313,7 @@ def missions(
"""
corpus = _load_or_fail(ctx)
mission_corpus = _load_missions_or_fail(ctx)
by_id = {c.id: c for c in corpus.cards}
by_id = corpus.by_id()

if query:
name = " ".join(query)
Expand Down Expand Up @@ -511,7 +532,7 @@ def build_deck_cmd(
from the opponent's weaknesses when omitted.
"""
corpus = _load_or_fail(ctx)
by_id = {c.id: c for c in corpus.cards}
by_id = corpus.by_id()

if counter_mission and counter_file:
console.print("[red]Use only one of --counter-mission / --counter-file.[/]")
Expand Down Expand Up @@ -647,9 +668,29 @@ def _resolve_opponent(
def show_deck_cmd(ctx: click.Context, path: Path) -> None:
"""Pretty-print a deck previously saved with --out."""
corpus = _load_or_fail(ctx)
payload = json.loads(path.read_text())
deck = DeckPlan.model_validate(payload["deck"])
by_id = {c.id: c for c in corpus.cards}

try:
payload = json.loads(path.read_text())
except json.JSONDecodeError as exc:
console.print(f"[red]Invalid deck file (not valid JSON):[/] {exc}")
raise SystemExit(2) from exc
if not isinstance(payload, dict):
console.print("[red]Invalid deck file:[/] expected a JSON object at the top level.")
raise SystemExit(2)
if "deck" not in payload:
console.print(
"[red]Invalid deck file:[/] missing [bold]deck[/] key. "
"Use a file saved with [bold]build-deck --out[/]."
)
raise SystemExit(2)

try:
deck = DeckPlan.model_validate(payload["deck"])
except ValidationError as exc:
console.print(f"[red]Invalid deck file:[/] {exc}")
raise SystemExit(2) from exc

by_id = corpus.by_id()
used = [by_id[e.card_id] for e in deck.cards if e.card_id in by_id]
warnings = payload.get("validation_warnings", [])
result = BuildResult(
Expand Down
10 changes: 1 addition & 9 deletions src/tcgp_deck_genie/deck_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field

from .gemini_client import GeminiClient
Expand Down Expand Up @@ -278,13 +277,6 @@ def _index_pokemon_by_name(corpus: list[Card]) -> dict[str, Card]:
# ---------------------------------------------------------------------------


def _parse_damage(damage: str | None) -> int:
if not damage:
return 0
m = re.search(r"\d+", damage)
return int(m.group()) if m else 0


def summarise_opponent(cards: list[Card], energy_types: list[str] | None = None) -> dict:
"""Pre-digest an opponent deck into the tactical reads a counter needs.

Expand Down Expand Up @@ -323,7 +315,7 @@ def summarise_opponent(cards: list[Card], energy_types: list[str] | None = None)
"attacks": [
{
"cost": "".join(e[0] for e in a.cost) if a.cost else "",
"dmg": _parse_damage(a.damage) or None,
"dmg": a.parsed_damage or None,
}
for a in c.attacks
],
Expand Down
9 changes: 9 additions & 0 deletions src/tcgp_deck_genie/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""
from __future__ import annotations

import re
from typing import Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator
Expand Down Expand Up @@ -48,6 +49,14 @@ class Attack(BaseModel):
def energy_cost_total(self) -> int:
return len(self.cost)

@property
def parsed_damage(self) -> int:
"""First integer in the damage string (e.g. ``'80+'`` → 80), or 0."""
if not self.damage:
return 0
m = re.search(r"\d+", self.damage)
return int(m.group()) if m else 0


class Ability(BaseModel):
"""A passive ability on a Pokémon card."""
Expand Down
9 changes: 1 addition & 8 deletions src/tcgp_deck_genie/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,18 +156,11 @@ def candidate_score(card: Card) -> float:
if card.abilities:
score += 1.5 * len(card.abilities)
if card.attacks:
best_dmg = max((_parse_damage(a.damage) for a in card.attacks), default=0)
best_dmg = max((a.parsed_damage for a in card.attacks), default=0)
score += best_dmg / 50.0
return score


def _parse_damage(d: str | None) -> int:
if not d:
return 0
m = re.search(r"\d+", d)
return int(m.group()) if m else 0


def top_candidates(cards: list[Card], limit: int) -> list[Card]:
"""Return the highest-scoring ``limit`` cards, ordered for stable prompts."""
if limit <= 0 or len(cards) <= limit:
Expand Down
Loading
Loading