#!/usr/bin/env python3 """Deterministic FIFA 17 base-card seed generator. Reads the committed, evidenced FIFA 17 data and produces: 1. the FIFA 17 card-definition *identity catalog* (openfut-adapter-fifa17 data): CardDefinitionId -> {asset_id, version=0}. `resource_id == asset_id` for version 0. 2. (optional) semantic base CardDefinitions for OpenFUT Core content. Base cards only (version 0). No specials — pool.json proves only base identities. CardDefinitionId is opaque + deterministic: `fifa17_` (per-game namespace). Core treats it as opaque; the FIFA meaning stays in the catalog. Sources (all committed, non-sensitive): fifa17-recon/data/pool.json real player assets (id, rating, pos, nation, league, team, attrs[pace,sho,pas,dri,def,phy], gk) fifa17-recon/data/roster.json names (id -> first/last/common) fifa17-recon/data/tables/{leagues,nations,teams}.json entity id -> name Determinism: output is sorted by asset_id and JSON-stable; running twice on the same source yields identical bytes. `--check` regenerates and diffs against the committed files, exiting non-zero on drift. """ import argparse import hashlib import json import os import sys GENERATOR_VERSION = 1 SCHEMA_VERSION = 1 GAME = "fifa17" RECON = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "fifa17-recon") def _load(path): with open(path, encoding="utf-8") as f: return json.load(f) def _table_map(path, id_key, name_key): doc = _load(path) return {row[id_key]: row[name_key] for row in doc["rows"]} def tier(overall): return "gold" if overall >= 75 else "silver" if overall >= 65 else "bronze" def build(recon): pool = _load(os.path.join(recon, "data", "pool.json")) roster = {r["id"]: r for r in _load(os.path.join(recon, "data", "roster.json"))} leagues = _table_map(os.path.join(recon, "data", "tables", "leagues.json"), "leagueid", "leaguename") nations = _table_map(os.path.join(recon, "data", "tables", "nations.json"), "nationid", "nationname") teams = _table_map(os.path.join(recon, "data", "tables", "teams.json"), "teamid", "teamname") catalog_cards = {} definitions = [] skipped = [] seen_assets = set() for p in sorted(pool, key=lambda x: x["id"]): asset = p["id"] if asset in seen_assets: skipped.append((asset, "duplicate_asset_id")) continue seen_assets.add(asset) if asset > 0x00FFFFFF: skipped.append((asset, "asset_id_exceeds_24_bits")) continue card_id = "fifa17_%d" % asset # Catalog entry needs only the asset id (identity); always emitted. catalog_cards[card_id] = {"asset_id": asset, "version": 0} # Semantic definition needs a name + resolvable entities; skip+report if not. r = roster.get(asset) name = None if r: common = (r.get("common") or "").strip() name = common or (" ".join(x for x in [r.get("first", ""), r.get("last", "")] if x)).strip() if not name: skipped.append((asset, "no_roster_name")) continue nation = nations.get(p["nation"]) league = leagues.get(p["league"]) club = teams.get(p["team"]) if not nation or not league or not club: missing = [k for k, v in (("nation", nation), ("league", league), ("club", club)) if not v] skipped.append((asset, "unresolved_entity:" + ",".join(missing))) continue a = p["attrs"] # [pace, shooting, passing, dribbling, defending, physical] definitions.append({ "id": card_id, "name": name, "overall": p["rating"], "position": p["pos"], "nation": nation, "league": league, "club": club, "pace": a[0], "shooting": a[1], "passing": a[2], "dribbling": a[3], "defending": a[4], "physical": a[5], "rarity": tier(p["rating"]), "image_path": None, }) catalog = { "schema_version": SCHEMA_VERSION, "game": GAME, "_provenance": { "generator": "scripts/seed_fifa17_cards.py", "generator_version": GENERATOR_VERSION, "sources": ["fifa17-recon/data/pool.json"], "base_cards_only": True, }, "cards": {cid: catalog_cards[cid] for cid in sorted(catalog_cards)}, } definitions.sort(key=lambda d: d["id"]) return catalog, definitions, skipped, len(pool) # ── Curated development subset ─────────────────────────────────────────────── # The dev pack is a small, deterministic slice of the full base-card set, chosen # to exercise the retail /club UI (quality/position/nation/league/team filters + # pagination) without loading all ~17.5K definitions. Selection is reproducible # from the sorted source; no asset is hand-picked. MY_SQUAD_PAGE = 11 # evidence: the client requests count=11 per page DEV_PRIMARY_GOLD_CAP = 18 # > one page → quality=gold and league= span 2+ pages DEV_SECONDARY_GOLD = 4 DEV_SILVER = 6 DEV_BRONZE = 4 def select_dev(definitions): """Deterministically curate a dev slice from the full definition set. Criteria (all reproducible): the two leagues with the most Gold cards are the primary/secondary leagues (tie-break by name); take the primary league's top Gold cards (capped), guaranteeing >1 page for both `quality=gold` and `league=`; ensure a GK is present; add the secondary league's top Golds (multiple leagues); add the top Silvers and Bronzes (quality spread). """ by_league = {} for d in definitions: by_league.setdefault(d["league"], []).append(d) golds = lambda ds: [d for d in ds if d["overall"] >= 75] ranked = sorted(by_league, key=lambda L: (-len(golds(by_league[L])), L)) primary, secondary = ranked[0], ranked[1] key = lambda d: (-d["overall"], d["id"]) chosen = {} for d in sorted(golds(by_league[primary]), key=key)[:DEV_PRIMARY_GOLD_CAP]: chosen[d["id"]] = d if not any(d["position"] == "GK" for d in chosen.values()): gks = sorted((d for d in definitions if d["position"] == "GK" and d["overall"] >= 75), key=key) if gks: chosen[gks[0]["id"]] = gks[0] for d in sorted(golds(by_league[secondary]), key=key)[:DEV_SECONDARY_GOLD]: chosen[d["id"]] = d for d in sorted((d for d in definitions if 65 <= d["overall"] < 75), key=key)[:DEV_SILVER]: chosen[d["id"]] = d for d in sorted((d for d in definitions if d["overall"] < 65), key=key)[:DEV_BRONZE]: chosen[d["id"]] = d return sorted(chosen.values(), key=lambda d: d["id"]), primary def dev_coverage(dev): """Coverage stats + the self-check gate the dev pack must pass.""" q = lambda lo, hi=256: sum(1 for d in dev if lo <= d["overall"] < hi) positions = sorted({d["position"] for d in dev}) teams = {} for d in dev: teams[d["club"]] = teams.get(d["club"], 0) + 1 cov = { "count": len(dev), "gold": q(75), "silver": q(65, 75), "bronze": q(0, 65), "positions": positions, "has_gk": "GK" in positions, "gold_st": sum(1 for d in dev if d["position"] == "ST" and d["overall"] >= 75), "leagues": sorted({d["league"] for d in dev}), "nations": sorted({d["nation"] for d in dev}), "max_same_team": max(teams.values()) if teams else 0, "pages_gold": (q(75) + MY_SQUAD_PAGE - 1) // MY_SQUAD_PAGE, } # The pack is only useful if it can exercise the filters + pagination. checks = { "gold_over_one_page": cov["gold"] > MY_SQUAD_PAGE, "has_gk": cov["has_gk"], "has_st": any(d["position"] == "ST" for d in dev), "multi_league": len(cov["leagues"]) >= 2, "multi_nation": len(cov["nations"]) >= 2, "has_silver": cov["silver"] >= 1, "has_bronze": cov["bronze"] >= 1, "same_team_group": cov["max_same_team"] >= 2, } cov["checks"] = checks cov["ok"] = all(checks.values()) return cov def dumps(obj): return json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=False) + "\n" def main(): ap = argparse.ArgumentParser() ap.add_argument("--recon", default=os.path.normpath(RECON)) ap.add_argument("--catalog-out", required=True) ap.add_argument("--defs-out", help="optional semantic CardDefinition JSON (full Core content)") ap.add_argument("--dev-out", help="curated game-scoped dev CardDefinition pack") ap.add_argument("--check", action="store_true", help="fail if committed output would change") args = ap.parse_args() catalog, definitions, skipped, pool_rows = build(args.recon) catalog_bytes = dumps(catalog) defs_bytes = dumps(definitions) dev, primary = select_dev(definitions) dev_bytes = dumps(dev) cov = dev_coverage(dev) cov["primary_league"] = primary reasons = {} for _, why in skipped: reasons[why.split(":")[0]] = reasons.get(why.split(":")[0], 0) + 1 report = { "pool_rows": pool_rows, "catalog_entries": len(catalog["cards"]), "definitions_seeded": len(definitions), "skipped_definitions": len(skipped), "skip_reasons": reasons, "catalog_sha256": hashlib.sha256(catalog_bytes.encode()).hexdigest()[:16], "dev_coverage": cov, } print(json.dumps(report, indent=2), file=sys.stderr) # The dev pack is worthless if it cannot exercise the filters + pagination. if args.dev_out and not cov["ok"]: print("DEV PACK FAILED COVERAGE GATE: %s" % cov["checks"], file=sys.stderr) sys.exit(2) # Every dev definition must have a catalog (FIFA render) identity. missing = [d["id"] for d in dev if d["id"] not in catalog["cards"]] if missing: print("DEV DEFS WITHOUT CATALOG IDENTITY: %s" % missing, file=sys.stderr) sys.exit(2) outputs = [(args.catalog_out, catalog_bytes)] if args.defs_out: outputs.append((args.defs_out, defs_bytes)) if args.dev_out: outputs.append((args.dev_out, dev_bytes)) if args.check: drift = False for path, want in outputs: have = open(path, encoding="utf-8").read() if os.path.exists(path) else None if have != want: print("DRIFT: %s is stale (regenerate)" % path, file=sys.stderr) drift = True sys.exit(1 if drift else 0) for path, data in outputs: os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) with open(path, "w", encoding="utf-8") as f: f.write(data) print("wrote catalog=%d dev=%d cards" % (len(catalog["cards"]), len(dev)), file=sys.stderr) if __name__ == "__main__": main()