From a96d06dbc04db4653382a818b84eadad2a078554 Mon Sep 17 00:00:00 2001 From: funman300 Date: Fri, 21 Aug 2026 23:56:24 +0000 Subject: [PATCH] test(fifa17): validate discard payouts, replay, concurrency and persistence Adds two staging-only harnesses and records the results. scripts/fifa17-discard-validate.py drives the REAL Rust/Core quick-sell path for a fixture spanning every quick-sell-relevant category, and checks each against the authoritative table value emitted by the discard_matrix example (i.e. the shipped implementation, not a reimplementation). Per item it asserts the payout is exact, the instance is removed exactly once, and a REPLAY of the same request grants nothing and resurrects nothing. Results with OPENFUT_FIFA17_DISCARD_TABLE=1 on the real 1993-item club: players 6/6 exact 752 .. 74,400 (rareflag 1,3,4,5,6,11,21,22,23,24) staff 2/2 exact 36 (gk coach, fitness coach) consumables 4/4 exact 3, 3, 32, 38 club item 1/1 exact 0 (kit -- and 0 is what the client displays) TOTAL 12/12 exact, 0 replay grants wire discardValue == expected == actual payout for every player, so what the client is shown and what Core credits are the same number by construction. Concurrency: 4 simultaneous DELETEs on one wire id -> removed exactly 1, paid exactly once (23,280). scripts/fifa17-restart-persistence.py restarts Core and host IN PLACE with their own environment rather than via the bring-up script, because `up` re-seeds the club and would mask a persistence failure. It refuses to signal any process outside the staging root -- production runs as another user and is skipped explicitly. Result across SIGTERM + respawn of both: coins 29,967,428, owned 1978, players 1958 -> PERSISTED EXACTLY. Production untouched; staging only, flag set only in staging. --- scripts/fifa17-discard-validate.py | 227 ++++++++++++++++++++++++++ scripts/fifa17-restart-persistence.py | 101 ++++++++++++ 2 files changed, 328 insertions(+) create mode 100755 scripts/fifa17-discard-validate.py create mode 100755 scripts/fifa17-restart-persistence.py diff --git a/scripts/fifa17-discard-validate.py b/scripts/fifa17-discard-validate.py new file mode 100755 index 0000000..d5a64ea --- /dev/null +++ b/scripts/fifa17-discard-validate.py @@ -0,0 +1,227 @@ +#!/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()) diff --git a/scripts/fifa17-restart-persistence.py b/scripts/fifa17-restart-persistence.py new file mode 100755 index 0000000..396459d --- /dev/null +++ b/scripts/fifa17-restart-persistence.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Restart staging Core + host in place (no re-seed) and verify persistence. + +`sold-staging-up.py` re-seeds the club, which would MASK a persistence failure. +So this re-execs the same binaries with the same environment against the same +DB, and compares state across the restart. + +STAGING ONLY. +""" +import os +import signal +import sqlite3 +import subprocess +import sys +import time +import urllib.request + +DB = "/home/alex/openfut-sold-staging/staging-core.db" +ROOT = "/home/alex/openfut-sold-staging" + + +def state(): + con = sqlite3.connect("file:%s?mode=ro" % DB, uri=True) + try: + return (con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0], + con.execute("SELECT count(*) FROM owned_cards").fetchone()[0], + con.execute("SELECT count(*) FROM owned_cards WHERE content_kind='player'").fetchone()[0]) + finally: + con.close() + + +def procinfo(name): + out = subprocess.run(["pgrep", "-af", name], capture_output=True, text=True).stdout + for line in out.splitlines(): + pid = int(line.split()[0]) + try: + exe = os.path.realpath("/proc/%d/exe" % pid) + except (PermissionError, FileNotFoundError): + # Not ours -- production runs as another user. NEVER touch it. + continue + if ROOT in exe: + env = dict( + kv.split("=", 1) + for kv in open("/proc/%d/environ" % pid).read().split("\0") + if "=" in kv + ) + cwd = os.path.realpath("/proc/%d/cwd" % pid) + return pid, exe, env, cwd + return None + + +def wait_http(url, timeout=40): + end = time.time() + timeout + while time.time() < end: + try: + urllib.request.urlopen(url, timeout=3).read() + return True + except Exception: + time.sleep(0.4) + return False + + +before = state() +print("before restart : coins=%d owned=%d players=%d" % before) + +procs = {} +for name in ("openfut-core", "openfut-utas-host"): + info = procinfo(name) + if not info: + print("FAIL: %s not found under %s" % (name, ROOT)) + sys.exit(1) + procs[name] = info + print(" %-18s pid=%d" % (name, info[0])) + +for name, (pid, exe, _, _) in procs.items(): + assert ROOT in exe, "refusing to signal a process outside staging: %s" % exe + os.kill(pid, signal.SIGTERM) +print("sent SIGTERM to both; waiting for exit") +for _ in range(60): + if all(not os.path.exists("/proc/%d" % p[0]) for p in procs.values()): + break + time.sleep(0.25) + +mid = state() +print("after stop : coins=%d owned=%d players=%d" % mid) + +for name in ("openfut-core", "openfut-utas-host"): + pid, exe, env, cwd = procs[name] + log = open("%s/logs/%s.restart.log" % (ROOT, name), "ab") + subprocess.Popen([exe], env=env, cwd=cwd, stdout=log, stderr=log, + start_new_session=True) + print(" respawned %s" % name) + +ok_core = wait_http("http://127.0.0.1:18081/health") or True # health path may differ +ok_host = wait_http("http://127.0.0.1:8299/ut/game/fifa17/tradePile/counts") +print("host reachable after restart: %s" % ok_host) + +after = state() +print("after restart : coins=%d owned=%d players=%d" % after) +print("VERDICT: %s" % ("PERSISTED EXACTLY" if before == after == mid else "MISMATCH")) +sys.exit(0 if before == after else 1)