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:
Executable
+227
@@ -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())
|
||||
Executable
+101
@@ -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)
|
||||
Reference in New Issue
Block a user