#!/usr/bin/env python3 """Authoritative Core state snapshot for FIFA17 FUT loop verification. Queries openfut-core's authoritative read API directly (NOT the FIFA UI, NOT the host projection, NOT Python) and emits a machine-readable JSON snapshot with the integrity fields needed to detect, across any economy operation or a full stack restart: * coin drift (balance vs club.coins cross-check + absolute value) * duplicate ownership (repeated owned_card_id) * missing ownership (squad references an owned_card_id not in the collection) * resurrection (owned-set hash changes when it must not) * entitlement dup/count (unopened pack entitlements) * stale squad mappings (squad player ids absent from ownership) This is verification/oracle tooling, not production behavior. It reads only. Usage: core-snapshot.py [--core URL] [--game fifa17] [--squad-ns fifa17.squad] [--out FILE] [--label NAME] Env fallbacks: OPENFUT_CORE_URL, OPENFUT_GAME, OPENFUT_SQUAD_NS. Exit code 0 always for a successful read; integrity problems are reported IN the snapshot (`integrity.ok` / `integrity.problems`) so callers can diff/assert. """ import argparse import hashlib import json import os import sys import urllib.error import urllib.request from datetime import datetime, timezone def fetch(core, game, path): req = urllib.request.Request(core + path, headers={"X-OpenFUT-Game": game}) try: with urllib.request.urlopen(req, timeout=15) as f: return f.status, f.read() except urllib.error.HTTPError as e: return e.code, e.read() except Exception as e: # noqa: BLE001 - surface transport errors in the snapshot return None, str(e).encode() def fetch_json(core, game, path): st, body = fetch(core, game, path) if st != 200: raise SystemExit(f"Core read {path} failed: status={st} body={body[:200]!r}") return json.loads(body) def sha(items): h = hashlib.sha256() for it in items: h.update(str(it).encode()) h.update(b"\x00") return h.hexdigest() def main(): ap = argparse.ArgumentParser() ap.add_argument("--core", default=os.environ.get("OPENFUT_CORE_URL", "http://127.0.0.1:18101")) ap.add_argument("--game", default=os.environ.get("OPENFUT_GAME", "fifa17")) ap.add_argument("--squad-ns", default=os.environ.get("OPENFUT_SQUAD_NS", "fifa17.squad")) ap.add_argument("--out") ap.add_argument("--label", default="") args = ap.parse_args() balance = fetch_json(args.core, args.game, "/economy/balance") entitlements = fetch_json(args.core, args.game, "/economy/entitlements") profile = fetch_json(args.core, args.game, "/profile") club = fetch_json(args.core, args.game, "/club") collection = fetch_json(args.core, args.game, "/collection")["collection"] squad = fetch_json(args.core, args.game, f"/squad/ext?namespace={args.squad_ns}") coins_balance = balance.get("balance") coins_club = club.get("coins") owned_ids = sorted(x["owned_card_id"] for x in collection) card_ids = sorted(x["card"]["id"] for x in collection) dup_owned = sorted({i for i in owned_ids if owned_ids.count(i) > 1}) if len(owned_ids) != len(set(owned_ids)) else [] loans = [x["owned_card_id"] for x in collection if x.get("is_loan")] # Squad player ownership references (from the opaque extension payload). squad_player_ids = [] ext = squad.get("extension") or {} payload_raw = ext.get("payload") kit_numbers = {} if payload_raw: try: payload = json.loads(payload_raw) kit_numbers = payload.get("kit_numbers", {}) or {} squad_player_ids = sorted(kit_numbers.keys()) except (json.JSONDecodeError, TypeError): pass owned_set = set(owned_ids) squad_missing = sorted(pid for pid in squad_player_ids if pid not in owned_set) problems = [] if coins_balance != coins_club: problems.append(f"coin cross-check mismatch: balance={coins_balance} club={coins_club}") if dup_owned: problems.append(f"duplicate owned_card_id: {dup_owned[:10]} (+{max(0,len(dup_owned)-10)} more)") if squad_missing: problems.append(f"squad references non-owned ids: {squad_missing}") snap = { "label": args.label, "captured_at": datetime.now(timezone.utc).isoformat(), "core": args.core, "game": args.game, "coins": {"balance": coins_balance, "club": coins_club}, "profile": {"username": profile.get("username"), "level": profile.get("level"), "xp": profile.get("xp")}, "entitlements": {"count": len(entitlements), "ids": entitlements}, "collection": { "owned": len(collection), "distinct_owned_card_id": len(set(owned_ids)), "distinct_card_id": len(set(card_ids)), "loans": len(loans), "duplicate_owned_card_id": dup_owned, "owned_set_sha256": sha(owned_ids), "card_multiset_sha256": sha(card_ids), }, "squad": { "namespace": args.squad_ns, "verdict": squad.get("verdict"), "player_count": len(squad_player_ids), "player_ids": squad_player_ids, "missing_from_ownership": squad_missing, "ext_schema_version": ext.get("schema_version"), }, "integrity": {"ok": not problems, "problems": problems}, } text = json.dumps(snap, indent=2, sort_keys=True) if args.out: with open(args.out, "w") as f: f.write(text + "\n") print(text) return 0 if __name__ == "__main__": sys.exit(main())