a96d06dbc0
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.
228 lines
7.7 KiB
Python
Executable File
228 lines
7.7 KiB
Python
Executable File
#!/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())
|