#!/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 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())
