70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
548 lines
29 KiB
Python
548 lines
29 KiB
Python
#!/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.
|
|
|
|
MOSTLY read-only: every check but one uses GET, so no pack is bought and no squad
|
|
is written. THE ONE EXCEPTION is test_club_rename_roundtrip, which PUTs a club
|
|
name to exercise the rename endpoint and RESTORES the original in a finally block.
|
|
(The docstring used to promise strictly read-only; that promise is now this
|
|
paragraph instead of a lie.)
|
|
|
|
Run: python3 tools/test_fut_contract.py
|
|
Exit 0 = all pass. No pytest dependency (stdlib only).
|
|
"""
|
|
import json, os, sys, urllib.error, urllib.request
|
|
|
|
BASE = os.environ.get("FUT_TEST_BASE", "http://127.0.0.1:8099")
|
|
G = "/ut/game/fifa17"
|
|
V2 = "/ut/v2/game/fifa17"
|
|
|
|
# IMPLEMENTATION-INDEPENDENT BY CONSTRUCTION.
|
|
# This suite talks to a server at a URL and imports NOTHING from the server's own
|
|
# code. That is what lets it verify ANY implementation of the reversed spec -- a
|
|
# future Rust openfut-core included -- without replaying the reverse engineering.
|
|
#
|
|
# It used to do `from fut_account import ACCOUNT` for the persona, which was a
|
|
# Python import against the Python implementation and quietly made the suite
|
|
# unable to certify a non-Python server. The expected persona now comes from the
|
|
# environment, defaulting to the value every layer has agreed on all along.
|
|
#
|
|
# The original reason for reading ACCOUNT still stands and is preserved: the suite
|
|
# and the server must not each hold their own copy of the constant, or the
|
|
# "identity is consistent" checks would only prove that two copies were copied
|
|
# correctly. Point FUT_TEST_PERSONA_ID at whatever the server under test is
|
|
# configured with; the default matches the shipped default.
|
|
PERSONA_ID = int(os.environ.get("FUT_TEST_PERSONA_ID", "33068179"))
|
|
|
|
_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 _req(method, path, body=None):
|
|
"""Returns (status, parsed-body). Never raises on 4xx/5xx -- the status itself
|
|
is a thing under test (FUT's rule is NEVER 4xx; see club_rename_route)."""
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
rq = urllib.request.Request(BASE + path, data=data, method=method,
|
|
headers={"Content-Type": "application/json"})
|
|
try:
|
|
with urllib.request.urlopen(rq, timeout=5) as r:
|
|
raw, code = r.read(), r.status
|
|
except urllib.error.HTTPError as e:
|
|
raw, code = e.read(), e.code
|
|
try:
|
|
return code, (json.loads(raw) if raw else {})
|
|
except ValueError:
|
|
return code, None # unparseable body -> caller fails the check
|
|
|
|
|
|
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.packContentInfo is object", is_obj(p.get("packContentInfo")))
|
|
# The inactive zero-item sentinel keeps FIFA's hardcoded `mypacks`
|
|
# navigation destination resolvable when no owned packs remain. It is
|
|
# intentionally neither owned nor purchasable and therefore has no
|
|
# pricing. Validate prices only for active store packs.
|
|
if p.get("state") == "active" and not p.get("unopened"):
|
|
check("store pack currencies is array (coin price)",
|
|
is_arr(p.get("currencies")))
|
|
check("store pack extPrice is object", is_obj(p.get("extPrice")))
|
|
ep = p.get("extPrice", {})
|
|
check("store extPrice.finalPrice is object",
|
|
is_obj(ep.get("finalPrice")))
|
|
elif p.get("unopened"):
|
|
check("owned pack omits purchase currencies", "currencies" not in p)
|
|
check("owned pack omits external purchase price", "extPrice" not in p)
|
|
|
|
|
|
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_auction_record_shape():
|
|
# Every populated auction record MUST match the reversed schema (deser
|
|
# 0x18013e410) field-for-field, or the market screen freezes. This proves the
|
|
# sample listings are freeze-safe OFFLINE, before the game ever parses them.
|
|
d = _get(G + "/auctionhouse?type=player&start=0&num=21")
|
|
recs = d.get("auctionInfo", [])
|
|
check("auctionhouse returns >=1 listing (or FUT_MARKET=empty)", is_arr(recs))
|
|
numeric = ["tradeId", "buyNowPrice", "startingBid", "currentBid", "expires",
|
|
"sellerEstablished", "coinsProcessed"]
|
|
strings = ["tradeState", "bidState", "sellerName"]
|
|
for r in recs:
|
|
check("record.itemData is object", is_obj(r.get("itemData")), repr(type(r.get("itemData"))))
|
|
check("record.watched is bool", isinstance(r.get("watched"), bool), repr(r.get("watched")))
|
|
for k in numeric:
|
|
check(f"record.{k} is number", is_num(r.get(k)), repr(r.get(k)))
|
|
for k in strings:
|
|
check(f"record.{k} is string", is_str(r.get(k)), repr(r.get(k)))
|
|
# itemData must itself be a valid card object (reuses club/squad parser)
|
|
it = r.get("itemData", {})
|
|
check("record.itemData.attributeList is array", is_arr(it.get("attributeList")))
|
|
check("record.itemData.resourceId is number", is_num(it.get("resourceId")))
|
|
|
|
|
|
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_squad_list_shape():
|
|
# userInfo.squadList (atom 0x2d4) goes through FUN_180142260 -- the same parser as
|
|
# the FutSquadList response -- so it must be an OBJECT with a "squad" ARRAY, never
|
|
# a bare array. Each element (0x180141fc0): rating/chemistry/id INT,
|
|
# formation/squadName/squadType STRING (string getter 0x1801c7aa0 + enum conv).
|
|
d = _get(G + "/user")
|
|
ui = d.get("userInfo", {})
|
|
check("user.userInfo is object", is_obj(ui), repr(type(ui)))
|
|
# Every STRING atom 0x18013ec10 consumes must be present and non-empty: a null
|
|
# string pointer in this record is what the 2026-08-03 create-club crash read.
|
|
for k in ("clubName", "clubAbbr", "established", "accountCreatedPlatformName"):
|
|
check(f"userInfo.{k} is non-empty string", is_str(ui.get(k)) and ui.get(k), repr(ui.get(k)))
|
|
# actives is optional (omitted by FUT_USERINFO=min); when present it must be an
|
|
# array of at most 5 item refs (0x18013ec10 stops storing past index 4).
|
|
act = ui.get("actives")
|
|
check("userInfo.actives absent or array", act is None or is_arr(act), repr(act))
|
|
check("userInfo.actives <= 5", len(act or []) <= 5)
|
|
# coins/record are what the hub renders: currencies elements are read by
|
|
# FUN_180138bd0 as name/funds/finalFunds/active -- "value" is NOT a key it knows.
|
|
coins = next((c for c in ui.get("currencies", []) if c.get("name") == "coins"), None)
|
|
check("userInfo has coins currency", coins is not None, repr(ui.get("currencies")))
|
|
if coins:
|
|
check("userInfo coins.funds is number", is_num(coins.get("funds")), repr(coins))
|
|
check("userInfo coins.finalFunds is number", is_num(coins.get("finalFunds")), repr(coins))
|
|
check("userInfo coins uses funds not value", "value" not in coins, repr(coins))
|
|
for k in ("won", "draw", "loss"):
|
|
check(f"userInfo.{k} is number", is_num(ui.get(k)), repr(ui.get(k)))
|
|
# REGRESSION GUARD, KEPT (not deleted -- the recon evidence does NOT show the
|
|
# new rename endpoint makes this safe; it shows the opposite: the crash chain
|
|
# runs entirely inside FIFA17.exe and never reaches our response).
|
|
# clubNameChangeAllowed=true is the isolated root cause of the 2026-08-03
|
|
# create-club crash (identical field set, only this bool flipped, 4/4 crash vs
|
|
# no crash). It may be absent, but it must never be true UNLESS the operator
|
|
# deliberately opted in with FUT_CLUB_RENAME=1 -- i.e. the guard now asserts
|
|
# the SAFE DEFAULT rather than blocking the opt-in experiment.
|
|
# The opt-in is keyed on a DISTINCT, test-only variable, NOT on FUT_CLUB_RENAME.
|
|
# Keying it on the same var the server reads means one exported FUT_CLUB_RENAME=1
|
|
# arms the crashing config AND silently disables the check that would catch it --
|
|
# the guard has to fail loudly in exactly that case, which is the whole point of
|
|
# having it. So: assert the safe default unless a human explicitly says "I am
|
|
# testing the rename experiment right now".
|
|
if os.environ.get("FUT_TEST_ALLOW_RENAME") == "1":
|
|
check("clubNameChangeAllowed is true under FUT_CLUB_RENAME=1",
|
|
ui.get("clubNameChangeAllowed") is True, repr(ui.get("clubNameChangeAllowed")))
|
|
else:
|
|
check("clubNameChangeAllowed is not true (default)",
|
|
ui.get("clubNameChangeAllowed") is not True, repr(ui.get("clubNameChangeAllowed")))
|
|
# squadList is OPTIONAL (FUT_USERINFO ladder) -- but if present it must be an
|
|
# object with a squad array, never a bare array.
|
|
sl = ui.get("squadList")
|
|
check("userInfo.squadList absent or object", sl is None or is_obj(sl), repr(sl))
|
|
if is_obj(sl):
|
|
check("squadList.squad is array", is_arr(sl.get("squad")), repr(sl.get("squad")))
|
|
for e in sl.get("squad", []):
|
|
check("squadList elem is object", is_obj(e), repr(e))
|
|
if not is_obj(e):
|
|
continue
|
|
for k in ("rating", "chemistry", "id"):
|
|
check(f"squadList.{k} is number", is_num(e.get(k)), repr(e.get(k)))
|
|
for k in ("formation", "squadName", "squadType"):
|
|
check(f"squadList.{k} is string", is_str(e.get(k)), repr(e.get(k)))
|
|
|
|
|
|
def test_squad_list_endpoint():
|
|
# GET ut/%s/squad/list is the real FutSquadList URL (live-observed 2026-08-03).
|
|
# Its parser 0x180142260 recognises ONLY squad(0x2cd), so the body MUST be
|
|
# {"squad":[...]}; returning the active-squad object here yields "MY SQUADS: 0".
|
|
d = _get(G + "/squad/list")
|
|
check("squad/list is object", is_obj(d), repr(type(d)))
|
|
check("squad/list has squad array", is_arr(d.get("squad")), repr(d)[:120])
|
|
check("squad/list is NOT the active-squad object", "players" not in d, repr(list(d)))
|
|
for e in d.get("squad", []):
|
|
for k in ("rating", "chemistry", "id"):
|
|
check(f"squad/list elem {k} is number", is_num(e.get(k)), repr(e.get(k)))
|
|
for k in ("formation", "squadName", "squadType"):
|
|
check(f"squad/list elem {k} is string", is_str(e.get(k)), repr(e.get(k)))
|
|
|
|
|
|
def test_massinfo_shape():
|
|
# GetUserMassInfo (deser 0x180174630) is a FLAT object -- no "user" wrapper.
|
|
# Populated as of 2026-08-03 (see FUT_RESPONSE_REBUILD_PLAN.md S7): userInfo,
|
|
# squad, settings, userData. Every member must keep its reversed type or the
|
|
# SAX reader desyncs -> busy-loop freeze at 0x1801c7f1a.
|
|
d = _get(G + "/userMassInfo")
|
|
check("massinfo is object", is_obj(d), repr(type(d)))
|
|
check("massinfo has no 'user' wrapper", "user" not in d, repr(list(d)))
|
|
if not d:
|
|
return # FUT_MASSINFO=empty bisect mode
|
|
for k in ("userInfo", "squad", "settings", "userData"):
|
|
if k in d:
|
|
check(f"massinfo.{k} is object", is_obj(d[k]), repr(type(d.get(k))))
|
|
sq = d.get("squad")
|
|
if is_obj(sq):
|
|
# squad(0x2cd) -> LoadActiveSquad parser 0x18013d1f0, same schema as GET /squad
|
|
check("massinfo.squad.players is array", is_arr(sq.get("players")), repr(type(sq.get("players"))))
|
|
check("massinfo.squad.formation is string", is_str(sq.get("formation")), repr(sq.get("formation")))
|
|
check("massinfo.squad.squadType is string", is_str(sq.get("squadType")), repr(sq.get("squadType")))
|
|
check("massinfo.squad.custom is string", is_str(sq.get("custom")), repr(type(sq.get("custom"))))
|
|
check("massinfo.squad.actives is array", is_arr(sq.get("actives")), repr(type(sq.get("actives"))))
|
|
check("massinfo.squad.manager is array", is_arr(sq.get("manager")), repr(type(sq.get("manager"))))
|
|
check("massinfo.squad.kicktakers is array", is_arr(sq.get("kicktakers")), repr(type(sq.get("kicktakers"))))
|
|
# personaId MUST equal the logged-in persona (0x18014659c) or SquadLoad
|
|
# discards our squad and builds a throwaway one.
|
|
check("massinfo.squad.personaId == PERSONA_ID", sq.get("personaId") == PERSONA_ID, repr(sq.get("personaId")))
|
|
if is_obj(d.get("settings")):
|
|
check("massinfo.settings.configs is array", is_arr(d["settings"].get("configs")),
|
|
repr(type(d["settings"].get("configs"))))
|
|
|
|
|
|
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 test_identity_consistency():
|
|
"""personaId must be IDENTICAL everywhere it is asserted.
|
|
|
|
This is the single check that would have caught any drift the old
|
|
seven-copies-of-a-literal layout could produce. The squad parser 0x18013d1f0
|
|
compares squad.personaId against the logged-in persona at 0x18014659c and, on
|
|
mismatch, silently builds a THROWAWAY squad (same comparison in 0x1801464e0
|
|
for summaries) -- so drift does not error, it just quietly loses your squad.
|
|
The merge FUN_18011e7c0 likewise matches clubUser records to club records on
|
|
personaId, so a mismatch there silently loses the gamertag.
|
|
"""
|
|
seen = {}
|
|
seen["userInfo.personaId"] = _get(G + "/user").get("userInfo", {}).get("personaId")
|
|
mi = _get(G + "/userMassInfo")
|
|
if "userInfo" in mi:
|
|
seen["massinfo.userInfo.personaId"] = mi["userInfo"].get("personaId")
|
|
if "squad" in mi:
|
|
seen["massinfo.squad.personaId"] = mi["squad"].get("personaId")
|
|
seen["squad.personaId"] = _get(G + "/squad/0").get("personaId")
|
|
cu = _get(G + "/clubUser").get("user") or []
|
|
if cu:
|
|
seen["clubUser.personaId"] = cu[0].get("personaId")
|
|
ul = _get(G + "/user/list?personaIdList=%d" % PERSONA_ID).get("user") or []
|
|
if ul:
|
|
seen["user/list.personaId"] = ul[0].get("personaId")
|
|
for where, v in seen.items():
|
|
check("%s == ACCOUNT.persona_id" % where, v == PERSONA_ID,
|
|
"%r != %r" % (v, PERSONA_ID))
|
|
check("personaId asserted in >=4 places", len(seen) >= 4, repr(sorted(seen)))
|
|
|
|
|
|
def test_club_user_shape():
|
|
"""GET /clubUser -- FutGetClubUsers (deser 0x180145c00), key `user`(0x36c).
|
|
|
|
REGRESSION THIS PINS: /clubUser used to be swallowed by the generic /club
|
|
route and answered {"itemData":[...]}, which GetClubUsers SKIPs entirely --
|
|
so the club-user (gamertag) model was empty by construction. Assert we are
|
|
NOT serving the itemData body.
|
|
"""
|
|
d = _get(G + "/clubUser")
|
|
check("clubUser is object", is_obj(d), repr(type(d)))
|
|
if d == {}:
|
|
return # FUT_CLUB_IDENTITY=off bisect rung
|
|
check("clubUser is NOT the itemData body", "itemData" not in d, repr(list(d)))
|
|
users = d.get("user")
|
|
check("clubUser.user is array", is_arr(users), repr(users))
|
|
for e in users or []:
|
|
check("clubUser elem is object", is_obj(e), repr(e))
|
|
if not is_obj(e):
|
|
continue
|
|
# persona(0x21a) STRING, bounded copy FUN_180008120(dst,s,0x21) -> 32 chars
|
|
p = e.get("persona")
|
|
check("clubUser.persona is non-empty string", is_str(p) and p, repr(p))
|
|
check("clubUser.persona <= 32 chars", is_str(p) and len(p) <= 32, repr(p))
|
|
check("clubUser.personaId is number", is_num(e.get("personaId")), repr(e.get("personaId")))
|
|
check("clubUser.public is bool", isinstance(e.get("public"), bool), repr(e.get("public")))
|
|
|
|
|
|
def test_club_info_shape():
|
|
"""GET /user/list -- club-identity records.
|
|
|
|
established MUST be a STRING of digits: userInfo deser 0x18013ec10 case 0x110
|
|
uses the STRING getter then strtol base 10. squadList(0x2d4) must be ABSENT or
|
|
an object with a squad array -- a bare array/scalar there goes to FUN_180142260
|
|
and is the 0x1801c7f1a busy-loop class.
|
|
"""
|
|
d = _get(G + "/user/list?personaIdList=%d" % PERSONA_ID)
|
|
check("user/list is object", is_obj(d), repr(type(d)))
|
|
if d == {}:
|
|
return # FUT_CLUB_IDENTITY=off bisect rung
|
|
users = d.get("user")
|
|
check("user/list.user is array", is_arr(users), repr(users))
|
|
for e in users or []:
|
|
check("user/list elem is object", is_obj(e), repr(e))
|
|
if not is_obj(e):
|
|
continue
|
|
check("user/list.personaId is number", is_num(e.get("personaId")), repr(e.get("personaId")))
|
|
for k in ("clubName", "clubAbbr"):
|
|
check(f"user/list.{k} is non-empty string", is_str(e.get(k)) and e.get(k), repr(e.get(k)))
|
|
est = e.get("established")
|
|
check("user/list.established is string", is_str(est), repr(est))
|
|
check("user/list.established is digits", is_str(est) and est.isdigit(), repr(est))
|
|
sl = e.get("squadList")
|
|
check("user/list.squadList absent or object", sl is None or is_obj(sl), repr(sl))
|
|
if is_obj(sl):
|
|
check("user/list.squadList.squad is array", is_arr(sl.get("squad")), repr(sl))
|
|
|
|
|
|
def test_accountinfo_shape():
|
|
# GET /user/accountinfo: {} by default and that is DELIBERATE -- its parser
|
|
# (FutGetUserAccountInfoServerCallConfig) is inside the Denuvo-packed
|
|
# FIFA17.exe and cannot be reversed, so key TYPES are unknown and any invented
|
|
# container is a freeze candidate. Under FUT_ACCOUNTINFO=1 every value must
|
|
# still be a scalar; nothing here may be an array or object.
|
|
d = _get(G + "/user/accountinfo")
|
|
check("accountinfo is object", is_obj(d), repr(type(d)))
|
|
for k, v in (d or {}).items():
|
|
check(f"accountinfo.{k} is scalar (no guessed containers)",
|
|
not isinstance(v, (list, dict)), repr(v))
|
|
|
|
|
|
def test_club_rename_roundtrip():
|
|
"""PUT the ChangeClubName endpoint(s) and prove the name persists.
|
|
|
|
THE ONLY MUTATING TEST IN THIS FILE -- it restores the original club in a
|
|
finally block.
|
|
|
|
FutChangeClubNameServerResponse has ZERO atoms (vtable 0x18022cb58 slot +0x08
|
|
= 0x1801642c0, body `return 1`), so the response body is fully ignored and {}
|
|
is complete. What is actually under test:
|
|
* HTTP 200, NEVER 4xx -- CardsDLL's failure reporter FUN_18016cca0 skips the
|
|
'R4ER: DISCONNECTED' telemetry path only while status==200, so answering
|
|
4xx is how a rejected name becomes a disconnect.
|
|
* the new name is reflected in userInfo (write-back parity with the client's
|
|
own FUN_1800829c0 -> rec+0x20 / rec+0x3e).
|
|
* BOTH competing URL derivations are routed (ENDPOINT_MAP row 3 says PUT
|
|
ut/%s/club; the recon says ut/%s/user + "/club" suffix appender 0x18014c740).
|
|
* an over-long abbr is REJECTED, not echoed: the client's write-back buffer
|
|
at userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,"%s",abbr).
|
|
"""
|
|
orig = _get(G + "/user").get("userInfo", {})
|
|
o_name, o_abbr = orig.get("clubName"), orig.get("clubAbbr")
|
|
check("rename precondition: original club readable",
|
|
is_str(o_name) and is_str(o_abbr), repr((o_name, o_abbr)))
|
|
if not (is_str(o_name) and is_str(o_abbr)):
|
|
return
|
|
try:
|
|
for path in (G + "/user/club", G + "/club"):
|
|
code, body = _req("PUT", path, {"clubName": "TestClub", "clubAbbr": "TST"})
|
|
check(f"PUT {path} -> 200 (never 4xx)", code == 200, repr(code))
|
|
check(f"PUT {path} body is parseable object", is_obj(body), repr(body))
|
|
ui = _get(G + "/user").get("userInfo", {})
|
|
check(f"PUT {path} applied clubName", ui.get("clubName") == "TestClub", repr(ui.get("clubName")))
|
|
check(f"PUT {path} applied clubAbbr", ui.get("clubAbbr") == "TST", repr(ui.get("clubAbbr")))
|
|
# user/list must follow the same source of truth, or the merge
|
|
# FUN_18011e7c0 would show a stale club next to a fresh one.
|
|
ul = (_get(G + "/user/list?personaIdList=%d" % PERSONA_ID).get("user") or [{}])[0]
|
|
if ul:
|
|
check(f"PUT {path} reflected in user/list",
|
|
ul.get("clubName") in ("TestClub", None), repr(ul.get("clubName")))
|
|
# restore between the two URLs so each is tested from a known state
|
|
_req("PUT", G + "/user/club", {"clubName": o_name, "clubAbbr": o_abbr})
|
|
# over-long abbr: rejected (4 bytes incl. NUL at userInfo+0x3e), never echoed
|
|
code, _ = _req("PUT", G + "/user/club", {"clubName": "BadAbbrClub", "clubAbbr": "TOOLONG"})
|
|
check("over-long abbr still answers 200", code == 200, repr(code))
|
|
ui = _get(G + "/user").get("userInfo", {})
|
|
check("over-long abbr not echoed", ui.get("clubAbbr") != "TOOLONG", repr(ui.get("clubAbbr")))
|
|
check("over-long abbr <= 3 chars", len(ui.get("clubAbbr") or "") <= 3, repr(ui.get("clubAbbr")))
|
|
# too-short name (view-model FUN_180082c30 name_min_length=5) likewise
|
|
_req("PUT", G + "/user/club", {"clubName": "Ab", "clubAbbr": "AB"})
|
|
ui = _get(G + "/user").get("userInfo", {})
|
|
check("too-short name rejected", ui.get("clubName") != "Ab", repr(ui.get("clubName")))
|
|
finally:
|
|
_req("PUT", G + "/user/club", {"clubName": o_name, "clubAbbr": o_abbr})
|
|
ui = _get(G + "/user").get("userInfo", {})
|
|
check("original club restored", (ui.get("clubName"), ui.get("clubAbbr")) == (o_name, o_abbr),
|
|
repr((ui.get("clubName"), ui.get("clubAbbr"))))
|
|
|
|
|
|
def test_move_verdict_shape():
|
|
"""PUT /item must return per-item VERDICT records. This guards the fix for the
|
|
project's longest-lived bug.
|
|
|
|
FutMoveCard's deserializer does not parse an acknowledgement, it builds a vector
|
|
of verdict records, and the completion handler raises
|
|
EVENT_CARDS_MOVE_CARD_FAILURE when that vector is EMPTY or when a record's
|
|
success byte (record+0x0c) is not 1. For seven attempts this endpoint answered
|
|
{}, an echo of the moved cards, or a dreamSquads stub, and every one of them told
|
|
the client the move had failed, so the client killed the FUT session. It looked
|
|
like a client-state bug for weeks because the HTTP status was always 200.
|
|
|
|
That failure mode is silent at the transport layer, which is exactly why it needs
|
|
a contract test: nothing else in this suite would notice a regression to {}.
|
|
|
|
NON-MUTATING. It asks to move ids that cannot exist, so no item changes pile. The
|
|
verdicts therefore come back success=false, which is the honest answer and is not
|
|
what is being asserted here. What is asserted is the SHAPE and the RECORD COUNT,
|
|
because an empty vector fails the client just as hard as a wrong flag.
|
|
"""
|
|
ghosts = [{"id": 999000001, "pile": "club", "swap": 0, "tradeId": 0},
|
|
{"id": 999000002, "pile": "club", "swap": 0, "tradeId": 0}]
|
|
code, body = _req("PUT", G + "/item", {"itemData": ghosts})
|
|
check("PUT /item -> 200", code == 200, repr(code))
|
|
check("PUT /item body is an object", is_obj(body), repr(body))
|
|
recs = body.get("itemData") if is_obj(body) else None
|
|
check("PUT /item returns itemData array (NOT {} -- {} reads as move-failed)",
|
|
is_arr(recs), repr(body)[:120])
|
|
if not is_arr(recs):
|
|
return
|
|
check("PUT /item returns one record per requested item",
|
|
len(recs) == len(ghosts), "%d records for %d items" % (len(recs), len(ghosts)))
|
|
for i, r in enumerate(recs):
|
|
check("record[%d] is an object" % i, is_obj(r), repr(r))
|
|
if not is_obj(r):
|
|
continue
|
|
# id(0x15c) INT via 0x1801c79d0 -- a string here desyncs the reader
|
|
check("record[%d].id is a number" % i, is_num(r.get("id")), repr(r.get("id")))
|
|
# pile(0x226) STRING via 0x1801c7aa0 -> enum 0x180142650
|
|
check("record[%d].pile is a string" % i, is_str(r.get("pile")), repr(r.get("pile")))
|
|
# success(0x2fa) BOOL via 0x1801c7620 -> record+0x0c
|
|
check("record[%d].success is a bool" % i, isinstance(r.get("success"), bool),
|
|
repr(r.get("success")))
|
|
|
|
|
|
def test_hub_counters():
|
|
"""GET /hub must carry the tile counters as INTEGERS.
|
|
|
|
clubPlayers (atom 0x90) is the MY CLUB tile's big number: the hub body parser
|
|
FUN_180139610 is the ONLY writer of the field it lands in (R+0x3c, read by
|
|
FUN_1800b0250 as TEXT0 of TILE_ID 0x210). auctionCount (0x33) feeds the TRANSFERS
|
|
tile the same way via R+0x38.
|
|
|
|
This route answered {} for the life of the project, which is why the tile read 0,
|
|
and a day was spent looking at /club/stats instead. A regression to {} would be
|
|
silent: still 200, still valid JSON, tile quietly back to zero.
|
|
|
|
Both are read with the INT getter 0x1801c79d0, so a string here would desync.
|
|
"""
|
|
d = _get(G + "/hub")
|
|
check("hub body is an object", is_obj(d), repr(d))
|
|
if not is_obj(d):
|
|
return
|
|
check("hub.clubPlayers present (the MY CLUB tile counter)", "clubPlayers" in d, repr(sorted(d)))
|
|
check("hub.clubPlayers is a number, not a string", is_num(d.get("clubPlayers")),
|
|
repr(d.get("clubPlayers")))
|
|
check("hub.auctionCount is a number", is_num(d.get("auctionCount")),
|
|
repr(d.get("auctionCount")))
|
|
# the clamp FUN_1800d7b30 turns <=0 into 0, so a negative would silently read as 0
|
|
if is_num(d.get("clubPlayers")):
|
|
check("hub.clubPlayers is not negative (clamped to 0 by the client)",
|
|
d["clubPlayers"] >= 0, repr(d["clubPlayers"]))
|
|
|
|
|
|
def main():
|
|
tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies,
|
|
test_auction_record_shape, test_squad_boot, test_squad_list_shape,
|
|
test_squad_list_endpoint, test_massinfo_shape, test_club_items,
|
|
test_identity_consistency, test_club_user_shape, test_club_info_shape,
|
|
test_accountinfo_shape, test_club_rename_roundtrip,
|
|
test_move_verdict_shape, test_hub_counters]
|
|
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())
|