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.
This commit is contained in:
funman300
2026-08-21 23:56:24 +00:00
parent 06d94bb37d
commit a96d06dbc0
2 changed files with 328 additions and 0 deletions
+227
View File
@@ -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())
+101
View File
@@ -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)