feat(fifa17): deterministic base-card seed generator + full identity catalog

scripts/seed_fifa17_cards.py: deterministic pipeline from committed FIFA17 data
(pool.json + roster.json + leagues/nations/teams tables) -> the card-definition
identity catalog. CardDefinitionId is opaque + deterministic (fifa17_<asset>),
version 0 (base cards only; resource_id == asset_id). --check mode diffs against
committed output (drift-detection mutation-proven). Provenance embedded.

Generated openfut-adapter-fifa17/data/fifa17-card-identities.json: all 17,563
base assets. Semantic definition coverage (to /tmp, not committed here): 17,547
resolvable; 16 skipped for missing roster name (reported, never fabricated).

Adapter loads the committed catalog (test: 17,563 entries, Ronaldo fifa17_20801
-> asset 20801 v0). Phase commit 3/5. NOT owned inventory: this is 'which cards
exist', not 'which the user owns'. Core content seeding + dev-owned set next.
This commit is contained in:
funman300
2026-08-11 22:19:57 +00:00
parent f55c401b6c
commit 36fe1caa3f
3 changed files with 70456 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
#!/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_<asset_id>` (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)
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 (Core content)")
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)
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],
}
print(json.dumps(report, indent=2), file=sys.stderr)
if args.check:
drift = False
for path, want in [(args.catalog_out, catalog_bytes)] + (
[(args.defs_out, defs_bytes)] if args.defs_out else []
):
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)
with open(args.catalog_out, "w", encoding="utf-8") as f:
f.write(catalog_bytes)
if args.defs_out:
with open(args.defs_out, "w", encoding="utf-8") as f:
f.write(defs_bytes)
print("wrote %s (%d cards)" % (args.catalog_out, len(catalog["cards"])), file=sys.stderr)
if __name__ == "__main__":
main()