fifa17-recon: contract/freeze-safety regression tests
Add tools/test_fut_contract.py -- stdlib-only, read-only tests that hit the live
utas_server and assert each response matches the shape reversed from CardsDLL
(docs/ENDPOINT_MAP.md). Encodes freeze-safety invariants (auctionInfo/players/
itemData/currencies/purchase must be array/object per the SAX deserializers;
scalar-where-container = busy-loop freeze at 0x1801c7f1a) plus the specific
contracts: v2/store gate == SUCCESS, coin counter binds currencies[coins].funds,
userMassInfo stays {}, empty squad slots carry itemData=null (proven-safe).
Catches the regression class that previously bit us (phantom packs, coins-0,
squad reset). 58 checks, all passing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Contract / freeze-safety regression tests for the FIFA 17 FUT backend.
|
||||
|
||||
Hits the LIVE utas_server (default http://127.0.0.1:8099) and asserts each
|
||||
response matches the shape reversed from CardsDLL (see docs/ENDPOINT_MAP.md).
|
||||
The point is freeze-safety: FIFA's SAX deserializers hard-freeze (busy-loop at
|
||||
0x1801c7f1a) if a field that must be an array/object arrives as a scalar. These
|
||||
tests encode "must be array" / "must be object" / "must be number" per the
|
||||
reversed schemas so a future edit that reintroduces that class of bug fails here
|
||||
instead of freezing the game.
|
||||
|
||||
Read-only: only GET endpoints are exercised (no pack buys / squad writes), so it
|
||||
never mutates the profile. Run: python3 tools/test_fut_contract.py
|
||||
Exit 0 = all pass. No pytest dependency (stdlib only).
|
||||
"""
|
||||
import json, sys, urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8099"
|
||||
G = "/ut/game/fifa17"
|
||||
V2 = "/ut/v2/game/fifa17"
|
||||
|
||||
_fail = []
|
||||
_pass = 0
|
||||
|
||||
|
||||
def _get(path):
|
||||
with urllib.request.urlopen(BASE + path, timeout=5) as r:
|
||||
raw = r.read()
|
||||
return json.loads(raw) if raw else {}
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
global _pass
|
||||
if cond:
|
||||
_pass += 1
|
||||
else:
|
||||
_fail.append(f"{name}: {detail}")
|
||||
|
||||
|
||||
def is_arr(x): return isinstance(x, list)
|
||||
def is_obj(x): return isinstance(x, dict)
|
||||
def is_num(x): return isinstance(x, (int, float)) and not isinstance(x, bool)
|
||||
def is_str(x): return isinstance(x, str)
|
||||
|
||||
|
||||
def test_credits():
|
||||
d = _get(G + "/user/credits")
|
||||
# coin counter binds to currencies[name==coins].funds (deser 0x180122c50), NOT "credits"
|
||||
check("credits.currencies is array", is_arr(d.get("currencies")), repr(d.get("currencies")))
|
||||
coins = next((c for c in d.get("currencies", []) if c.get("name") == "coins"), None)
|
||||
check("credits has coins currency", coins is not None)
|
||||
if coins:
|
||||
check("coins.funds is number", is_num(coins.get("funds")), repr(coins.get("funds")))
|
||||
|
||||
|
||||
def test_v2_store_gate():
|
||||
d = _get(V2 + "/store")
|
||||
# FutStorePackQuantities eligibility gate (deser 0x1801758c0): result must be SUCCESS
|
||||
check("v2/store result == SUCCESS", d.get("result") == "SUCCESS", repr(d))
|
||||
|
||||
|
||||
def test_store_catalog():
|
||||
d = _get(G + "/store/purchasegroup/all")
|
||||
# FutStoreGetPackTypes (deser 0x1801234e0): root key "purchase" MUST be an array
|
||||
check("catalog.purchase is array", is_arr(d.get("purchase")), repr(type(d.get("purchase"))))
|
||||
for p in d.get("purchase", []):
|
||||
check("pack has assetId (real identity)", "assetId" in p, repr(p.get("assetId")))
|
||||
check("pack.currencies is array (coin price)", is_arr(p.get("currencies")))
|
||||
check("pack.packContentInfo is object", is_obj(p.get("packContentInfo")))
|
||||
check("pack.extPrice is object", is_obj(p.get("extPrice")))
|
||||
ep = p.get("extPrice", {})
|
||||
check("extPrice.finalPrice is object", is_obj(ep.get("finalPrice")))
|
||||
|
||||
|
||||
def test_market_bodies():
|
||||
# Shared IS-list body (deser 0x18013e7f0): auctionInfo MUST be array, credits number
|
||||
for path in [G + "/auctionhouse?type=player&start=0&num=21",
|
||||
G + "/tradePile", G + "/watchList", G + "/trade/123"]:
|
||||
d = _get(path)
|
||||
check(f"{path} auctionInfo is array", is_arr(d.get("auctionInfo")), repr(d.get("auctionInfo")))
|
||||
check(f"{path} credits is number", is_num(d.get("credits")), repr(d.get("credits")))
|
||||
dup = _get(G + "/auctionhouse?type=player").get("duplicateItemIdList")
|
||||
check("auctionhouse duplicateItemIdList is array", is_arr(dup), repr(dup))
|
||||
md = _get(G + "/marketdata?defId=1")
|
||||
check("marketdata minPrice is number", is_num(md.get("minPrice")))
|
||||
check("marketdata maxPrice is number", is_num(md.get("maxPrice")))
|
||||
|
||||
|
||||
def test_squad_boot():
|
||||
# LoadActiveSquad (deser 0x18013d1f0): players MUST be array; empty body would reset
|
||||
# the 23 slots. formation is a string. This is the boot-critical path.
|
||||
d = _get(G + "/squad/0")
|
||||
check("squad.players is array", is_arr(d.get("players")), repr(type(d.get("players"))))
|
||||
check("squad.formation is string", is_str(d.get("formation")), repr(d.get("formation")))
|
||||
for pl in d.get("players", []):
|
||||
# Empty bench/reserve slots legitimately carry itemData=null (proven-safe,
|
||||
# matches the working in-game squad). Only a PRESENT itemData must be an
|
||||
# object -- a scalar there would desync the reader.
|
||||
it = pl.get("itemData")
|
||||
check("squad slot itemData is object-or-null", it is None or is_obj(it), repr(it))
|
||||
|
||||
|
||||
def test_massinfo_empty():
|
||||
# GetUserMassInfo (deser 0x180174630) MUST stay {} -- any populated userInfo/squad
|
||||
# desyncs the parser -> freeze (CARD_SYSTEM.md). Guard against accidental population.
|
||||
d = _get(G + "/userMassInfo")
|
||||
check("userMassInfo is empty {}", d == {}, repr(d))
|
||||
|
||||
|
||||
def test_club_items():
|
||||
# /club serves {"itemData":[...]} (SKIP'd by GetClubInfo, but must stay array-safe)
|
||||
d = _get(G + "/club?type=player&count=5")
|
||||
check("club.itemData is array", is_arr(d.get("itemData")), repr(type(d.get("itemData"))))
|
||||
|
||||
|
||||
def main():
|
||||
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
|
||||
test_squad_boot, test_massinfo_empty, test_club_items]
|
||||
try:
|
||||
_get(G + "/user/credits")
|
||||
except Exception as e:
|
||||
print(f"SERVER NOT REACHABLE at {BASE}: {e}\nStart it: python3 tools/utas_server.py")
|
||||
return 2
|
||||
for t in tests:
|
||||
try:
|
||||
t()
|
||||
except Exception as e:
|
||||
_fail.append(f"{t.__name__} raised {type(e).__name__}: {e}")
|
||||
print(f"\n{_pass} checks passed, {len(_fail)} failed")
|
||||
for f in _fail:
|
||||
print(" FAIL:", f)
|
||||
return 0 if not _fail else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user