Files
OpenFUT/fifa17-recon/tools/test_fut_contract.py
T
funman300 b7943aead3 fifa17-recon: contract test guarding the move-verdict shape (392 checks)
The bug we just closed was invisible to every existing check: PUT /item answered 200
with a well-formed JSON body, and the body told the client the move had FAILED. Seven
attempts, weeks of investigation, and nothing in the suite would have noticed a
regression back to {}.

test_move_verdict_shape asserts the record vector exists, has ONE RECORD PER REQUESTED
ITEM (an empty vector fails the client exactly as hard as a wrong field), and that
id/pile/success carry the number/string/bool types their getters require.

Non-mutating: it asks to move ids that cannot exist, so nothing changes pile. The
verdicts come back success=false, which is honest and is not what is asserted.

Verified it actually catches the regression rather than just passing:
  default (ack)        392 checks passed, 0 failed
  FUT_MOVE_BODY=empty  382 passed, 1 FAILED -- "returns itemData array"

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
2026-08-04 11:32:04 -07:00

509 lines
27 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.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_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 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]
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())