#!/usr/bin/env python3 """Validate FIFA17 quick-sell against the recovered discard table, end to end. STAGING ONLY. Refuses to run against anything but the staging host/DB, and never touches production. For a deterministic fixture drawn from every quick-sell-relevant category it: 1. snapshots coins + ownership 2. quick-sells through the real Rust/Core path 3. asserts the payout equals the authoritative table value for that definition (from `cargo run --example discard_matrix`, i.e. the shipped implementation) 4. asserts the instance is removed exactly once and nothing else moved 5. REPLAYS the same request and asserts no second grant and no resurrection 6. (optionally) restarts and re-checks persistence 7. runs concurrent duplicate sells and asserts exactly one wins Usage: python3 scripts/fifa17-discard-validate.py --matrix /tmp/discard-matrix.csv python3 scripts/fifa17-discard-validate.py --matrix ... --concurrency """ import argparse import collections import csv import json import sqlite3 import sys import threading import urllib.error import urllib.request HOST = "http://127.0.0.1:8299" DB = "/home/alex/openfut-sold-staging/staging-core.db" HDRS = {"X-OpenFUT-Game": "fifa17"} FORBIDDEN = ("/home/alex/openfut-promotion",) def guard(): for f in FORBIDDEN: if DB.startswith(f): raise SystemExit("refusing: DB path is production") if "8299" not in HOST: raise SystemExit("refusing: host is not staging :8299") def get(path): req = urllib.request.Request(HOST + path, headers=HDRS) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read()) def delete(path): req = urllib.request.Request(HOST + path, headers=HDRS, method="DELETE") try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, json.loads(r.read()) except urllib.error.HTTPError as e: return e.code, None def db(query, args=()): con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True) try: return con.execute(query, args).fetchall() finally: con.close() def coins(): return db("SELECT coins FROM clubs LIMIT 1")[0][0] def owned_count(): return db("SELECT count(*) FROM owned_cards")[0][0] def owned_by_kind(): return dict(db("SELECT content_kind, count(*) FROM owned_cards GROUP BY content_kind")) def load_matrix(path): out = {} with open(path) as fh: for row in csv.DictReader(fh): out[row["definition"]] = row return out def collect_fixture(): """One representative owned instance per (kind, rating band, rareflag).""" picks = [] seen = set() def add(kind, it, note): rid = it.get("resourceId") wire = it.get("id") if rid is None or wire is None: return key = (kind, it.get("rating", 0) // 10, it.get("rareflag", 0)) if key in seen: return seen.add(key) picks.append({ "kind": kind, "wire": wire, "definition": "fifa17_%d" % rid, "rating": it.get("rating"), "rareflag": it.get("rareflag"), "subtype": it.get("cardsubtypeid"), "wire_discard": it.get("discardValue"), "note": note, }) club = get("/ut/game/fifa17/club?type=player&start=0&count=200") for it in club.get("itemData") or []: add("player", it, "club player") for tok in ("staff", "manager"): try: b = get("/ut/game/fifa17/club?type=%s&start=0&count=20" % tok) except Exception: continue for it in b.get("itemData") or []: add("staff", it, "club %s" % tok) for seg in ("contracts", "training", "fitness", "healing", "playStyle", "position"): try: b = get("/ut/game/fifa17/club/consumables/%s" % seg) except Exception: continue for st in b.get("itemData") or []: add("consumable", st.get("item", st), "consumable/%s" % seg) for tok in ("kit", "badge", "stadium", "ball", "misc"): try: b = get("/ut/game/fifa17/club?type=%s&start=0&count=10" % tok) except Exception: continue for it in b.get("itemData") or []: add("clubitem", it, "club %s" % tok) return picks def main(): ap = argparse.ArgumentParser() ap.add_argument("--matrix", required=True) ap.add_argument("--concurrency", action="store_true") ap.add_argument("--limit", type=int, default=14) ap.add_argument("--per-kind", type=int, default=6, help="cap per content kind so every class is covered") a = ap.parse_args() guard() matrix = load_matrix(a.matrix) allf = collect_fixture() per = collections.defaultdict(int) fixture = [] for f in allf: if per[f['kind']] >= a.per_kind: continue per[f['kind']] += 1 fixture.append(f) fixture = fixture[: a.limit] print("fixture: %d instance(s)\n" % len(fixture)) hdr = "%-10s %-16s %-5s %-4s %-5s %8s %8s %8s %s" print(hdr % ("kind", "definition", "sub", "rat", "rare", "wire", "expect", "paid", "verdict")) results = [] for f in fixture: row = matrix.get(f["definition"]) if row is None: print(hdr % (f["kind"], f["definition"], f["subtype"], f["rating"], f["rareflag"], f["wire_discard"], "?", "-", "NO MATRIX ROW")) results.append(("NO_MATRIX", f)) continue expect = int(row["recovered"]) if row["recovered"] != "-" else None before_c, before_n = coins(), owned_count() still = db("SELECT count(*) FROM owned_cards")[0][0] status, _ = delete("/ut/game/fifa17/item/%d" % f["wire"]) after_c, after_n = coins(), owned_count() paid = after_c - before_c removed = before_n - after_n ok = (paid == expect) and removed == 1 # replay: must not grant again, must not resurrect st2, _ = delete("/ut/game/fifa17/item/%d" % f["wire"]) replay_c, replay_n = coins(), owned_count() replay_ok = (replay_c == after_c) and (replay_n == after_n) verdict = "OK" if ok and replay_ok else ( "PAYOUT" if not ok else "REPLAY") print(hdr % (f["kind"], f["definition"], f["subtype"], f["rating"], f["rareflag"], f["wire_discard"], expect, paid, "%s%s" % (verdict, "" if replay_ok else " (replay granted!)"))) results.append((verdict, f)) bad = [r for r in results if r[0] != "OK"] print("\n%d/%d exact; %d problem(s)" % (len(results) - len(bad), len(results), len(bad))) if a.concurrency: print("\n=== concurrent duplicate quick-sell ===") rest = collect_fixture() target = next((x for x in rest if x["kind"] == "player"), None) if target: before_c, before_n = coins(), owned_count() out = [] def worker(): out.append(delete("/ut/game/fifa17/item/%d" % target["wire"])) ts = [threading.Thread(target=worker) for _ in range(4)] for t in ts: t.start() for t in ts: t.join() paid = coins() - before_c removed = before_n - owned_count() row = matrix.get(target["definition"]) expect = int(row["recovered"]) if row else None print(" 4 concurrent DELETEs on wire %d" % target["wire"]) print(" removed=%d (want 1) paid=%d (want %s)" % (removed, paid, expect)) print(" VERDICT: %s" % ("OK" if removed == 1 and paid == expect else "FAIL")) print("\nownership by kind now: %s" % owned_by_kind()) return 1 if bad else 0 if __name__ == "__main__": sys.exit(main())