From bd7cd93b482e2cb4a932f2505b62e584c11f7108 Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 23 Jul 2026 00:23:10 +0200 Subject: [PATCH] =?UTF-8?q?M1:=20fetch-static=20=E2=80=94=20cdragon=20down?= =?UTF-8?q?load,=20parse,=20icon=20URLs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/tft/cli.py | 24 ++++++++ backend/tft/paths.py | 23 ++++++++ backend/tft/staticdata/__init__.py | 0 backend/tft/staticdata/fetch.py | 55 ++++++++++++++++++ backend/tft/staticdata/parse.py | 90 ++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+) create mode 100644 backend/tft/cli.py create mode 100644 backend/tft/paths.py create mode 100644 backend/tft/staticdata/__init__.py create mode 100644 backend/tft/staticdata/fetch.py create mode 100644 backend/tft/staticdata/parse.py diff --git a/backend/tft/cli.py b/backend/tft/cli.py new file mode 100644 index 0000000..480d0e1 --- /dev/null +++ b/backend/tft/cli.py @@ -0,0 +1,24 @@ +import argparse + + +def main() -> None: + parser = argparse.ArgumentParser(prog="tft") + sub = parser.add_subparsers(dest="command", required=True) + + p_fetch = sub.add_parser("fetch-static", help="download static data from Community Dragon") + p_fetch.add_argument("--set", type=int, default=None, help="override set number") + + args = parser.parse_args() + + if args.command == "fetch-static": + from tft.staticdata.fetch import fetch_static + + parsed = fetch_static(set_override=args.set) + meta = parsed["meta"] + print(f"set {meta['set']} ({meta['set_name']}), patch {meta['patch']}") + for name in ("units", "traits", "items", "augments"): + print(f" {name}: {len(parsed[name])}") + + +if __name__ == "__main__": + main() diff --git a/backend/tft/paths.py b/backend/tft/paths.py new file mode 100644 index 0000000..fec5d4b --- /dev/null +++ b/backend/tft/paths.py @@ -0,0 +1,23 @@ +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +DATA = REPO_ROOT / "data" +STATIC = DATA / "static" +ARTIFACTS = DATA / "artifacts" +MATCHES_DB = DATA / "matches.sqlite" + + +def static_dir(set_number: int, patch: str) -> Path: + return STATIC / f"set{set_number}_{patch}" + + +def latest_static_pointer() -> Path: + return STATIC / "latest.json" + + +def artifact_dir(set_number: int, build_date: str) -> Path: + return ARTIFACTS / f"set{set_number}" / build_date + + +def latest_artifact_pointer(set_number: int) -> Path: + return ARTIFACTS / f"set{set_number}" / "latest.json" diff --git a/backend/tft/staticdata/__init__.py b/backend/tft/staticdata/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tft/staticdata/fetch.py b/backend/tft/staticdata/fetch.py new file mode 100644 index 0000000..cb82f7a --- /dev/null +++ b/backend/tft/staticdata/fetch.py @@ -0,0 +1,55 @@ +"""Download TFT static data from Community Dragon and normalize it.""" + +import json +from datetime import date + +import requests + +from tft import paths +from tft.staticdata.parse import parse + +CDRAGON = "https://raw.communitydragon.org/latest" +METADATA_URL = f"{CDRAGON}/content-metadata.json" +TFT_DATA_URL = f"{CDRAGON}/cdragon/tft/en_us.json" + + +def fetch_static(set_override: int | None = None) -> dict: + meta = requests.get(METADATA_URL, timeout=30) + meta.raise_for_status() + version = meta.json()["version"] + patch = ".".join(version.split(".")[:2]) + + resp = requests.get(TFT_DATA_URL, timeout=120) + resp.raise_for_status() + raw = resp.json() + + parsed = parse(raw, set_override=set_override) + set_number = parsed["meta"]["set"] + + out_dir = paths.static_dir(set_number, patch) + out_dir.mkdir(parents=True, exist_ok=True) + + (out_dir / "raw.json").write_text(json.dumps(raw)) + parsed["meta"]["patch"] = patch + parsed["meta"]["game_version"] = version + parsed["meta"]["fetched_at"] = date.today().isoformat() + for name in ("units", "traits", "items", "augments", "meta"): + (out_dir / f"{name}.json").write_text( + json.dumps(parsed[name], indent=1, ensure_ascii=False) + ) + + pointer = {"set": set_number, "patch": patch, "dir": str(out_dir)} + paths.latest_static_pointer().write_text(json.dumps(pointer, indent=1)) + return parsed + + +def load_static() -> dict: + """Load the most recently fetched static data.""" + from pathlib import Path + + pointer = json.loads(paths.latest_static_pointer().read_text()) + static_dir = Path(pointer["dir"]) + return { + name: json.loads((static_dir / f"{name}.json").read_text()) + for name in ("units", "traits", "items", "augments", "meta") + } diff --git a/backend/tft/staticdata/parse.py b/backend/tft/staticdata/parse.py new file mode 100644 index 0000000..0aee4ec --- /dev/null +++ b/backend/tft/staticdata/parse.py @@ -0,0 +1,90 @@ +"""Normalize the raw Community Dragon TFT JSON into flat unit/trait/item tables. + +Fails loudly on missing keys — never guess through structure drift. +""" + +CDRAGON_GAME = "https://raw.communitydragon.org/latest/game" + + +def icon_url(asset_path: str) -> str: + p = asset_path.lower() + for ext in (".tex", ".dds"): + if p.endswith(ext): + p = p[: -len(ext)] + ".png" + return f"{CDRAGON_GAME}/{p}" + + +def detect_set(raw: dict) -> int: + candidates = [ + int(k) for k, v in raw["sets"].items() if v.get("champions") + ] + if not candidates: + raise ValueError("no set with champions found in cdragon data") + return max(candidates) + + +def parse(raw: dict, set_override: int | None = None) -> dict: + set_number = set_override if set_override is not None else detect_set(raw) + set_data = raw["sets"][str(set_number)] + + units = {} + for c in set_data["champions"]: + if not c["traits"]: + continue # PvE monsters, summons + units[c["apiName"]] = { + "api_name": c["apiName"], + "name": c["name"], + "cost": c["cost"], + "traits": c["traits"], + "role": c.get("role"), + "stats": c["stats"], + "ability_name": c["ability"]["name"], + "ability_variables": { + v["name"]: v["value"] for v in c["ability"]["variables"] + }, + "icon": icon_url(c["squareIcon"]), + } + + traits = {} + trait_name_to_api = {} + for t in set_data["traits"]: + traits[t["apiName"]] = { + "api_name": t["apiName"], + "name": t["name"], + "breakpoints": [ + {"min_units": e["minUnits"], "style": e["style"]} + for e in t["effects"] + ], + "icon": icon_url(t["icon"]), + } + trait_name_to_api[t["name"]] = t["apiName"] + + # Champion traits are display names; map them to api names. + for u in units.values(): + u["traits"] = [trait_name_to_api[name] for name in u["traits"]] + + set_prefix = f"TFT{set_number}_" + items = {} + augments = {} + for i in raw["items"]: + api = i["apiName"] + if not (api.startswith(set_prefix) or api.startswith("TFT_Item_")): + continue + entry = { + "api_name": api, + "name": i["name"], + "composition": i["composition"], + "icon": icon_url(i["icon"]) if i["icon"] else None, + } + if "Augment" in api: + augments[api] = entry + else: + items[api] = entry + + return { + "meta": {"set": set_number, "set_name": set_data["name"]}, + "units": units, + "traits": traits, + "items": items, + "augments": augments, + }