#!/usr/bin/env python3
"""Minimal FIFA 17 UTAS/RS4 server (OpenFUT, clean-room).

CardsDLL resolves every RS4 endpoint to <base> + path, where <base> comes from
FUT_RS4_APIURL_<MODULE> / FUT_RS4_URL_<CALL> (blaze_responder_v3b.py) and path is
moduleTable[i] with %s -> "game/<sku>".  Auth is POST <base>ut/auth; the response's
"sid" becomes the X-UT-SID header on every later call (CardsDLL @0x180126080).

Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
  * NEVER 401/403  -> silent re-auth storm (3 retries) then ServerFatalError.
  * body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
  * [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, sys, http.server

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary   # forged starter squad (clean-room)
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item  # profile + packs
from fut_account import ACCOUNT, validate_club                # identity + club, single source

# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
# one the live client is talking to. Research agents kept bouncing the live server
# because the only way to exercise a route was to restart the only server there was;
# with this plus FUT_PROFILE (a copy of the save) and FUT_TEST_BASE, a test run is
# fully isolated. The default stays 8099: that is the port the hook redirects to.
ADDR = ("127.0.0.1", int(os.environ.get("FUT_PORT", "8099")))
LOG = os.environ.get("FUT_LOG", "/tmp/utas_server.log")
SID = "OPENFUT-SID-0000000000000001"
# IDENTITY NOTE: there are no PERSONA_ID / PERSONA_NAME literals in this file any
# more. They lived here, in fut_store.py, fut_seed.py, blaze_responder_v3b.py and
# lsx_responder_v2.py -- five files, seven copies of the same two values. The stack
# only works while Blaze SESS.PDTL, LSX GetProfileResponse and the UTAS
# userInfo/squad bodies all assert the SAME persona, so every one of them now reads
# fut_account.ACCOUNT. Read ACCOUNT.persona_id LIVE at call time (never snapshot it
# into a module constant) -- POST /ut/auth can adopt a different persona from the
# client's own body at runtime, and a snapshot would silently keep the old value.
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False


def now():
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


def log(m):
    line = "[%s] %s" % (datetime.datetime.now().strftime("%H:%M:%S"), m)
    print(line, flush=True)
    with open(LOG, "a") as f:
        f.write(line + "\n")


# ---- payloads -------------------------------------------------------------
def auth_body(h=None):
    """POST ut/auth.

    RESPONSE: only "sid" is load-bearing (parser 0x1801a2880 -> ServerCall+0x1e8).
    serverTime/lastOnlineTime are atoi'd at char offsets 0,5,8,11,14,17.

    REQUEST: the client TELLS us who it is and we used to throw that away. The
    body is built by CardsDLL FUN_180125900 and was live-logged byte-identical on
    three separate runs:
        {"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
         "nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
         "locale":"en-US","regionCode":"US",...}
    Adopting it makes the squad personaId comparison at 0x18014659c correct BY
    CONSTRUCTION instead of by matching literals: the squad parser 0x18013d1f0
    stores personaId (atom 0x21b) at squad+0x38 and compares it against
    FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a THROWAWAY squad
    rather than erroring, so a drifted id looks like "my squad reset itself".

    RECONCILIATION RULE: the wire is truth, fut_account.json is a cache. Adopt and
    WARN, never refuse -- a mismatch is the NORMAL state on the first boot after a
    rename. FUT_ADOPT_AUTH=0 disables adoption (documented escape hatch).
    """
    if h is not None:
        try:
            body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
        except Exception:
            body = None
        if isinstance(body, dict):
            before = (ACCOUNT.persona_id, ACCOUNT.persona_name)
            try:
                if ACCOUNT.adopt_from_auth(body):
                    log("    AUTH: adopted persona %s/%r (was %s/%r)"
                        % (ACCOUNT.persona_id, ACCOUNT.persona_name, before[0], before[1]))
                    # Keep the game save's identity mirror in step with ACCOUNT so
                    # tradepile/club readers cannot lag a session behind.
                    STORE.refresh_identity()
            except Exception as e:                # adoption must never break auth
                log("    AUTH: adopt failed (%s: %s) -- keeping %s/%r"
                    % (type(e).__name__, e, before[0], before[1]))
    return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}


def current_squad():
    """The squad the client should see: the persisted one (item refs re-embedded
    from the club) or the seed ladder squad on first run.

    personaId is FORCED to ACCOUNT.persona_id: SquadLoad compares it against the
    logged-in persona at 0x18014659c and, on mismatch, takes the vtable[0x4f0]
    branch and builds a throwaway squad instead of adopting ours.
    """
    saved = STORE.active_squad()
    # Overlay the saved squad on the seed envelope: FIFA's PUT body carries only
    # what it changed, so this keeps a valid custom(0xc6) 33-int string, kicktakers
    # and squadName present on reload instead of silently dropping them.
    sq = dict(SQUAD)
    if saved:
        sq.update(STORE.reconstruct_squad(saved))
    sq["personaId"] = ACCOUNT.persona_id
    sq.setdefault("id", 0)
    return sq


def squad_list_body(squad=None):
    """FutSquadListServerResponse body (deser 0x180172140 -> value parser
    0x180142260 -> element 0x180141fc0). ONE key, "squad"(0x2cd), holding an ARRAY
    of squad summaries. This is the same parser userInfo.squadList goes through."""
    return {"squad": [squad_summary(squad or current_squad())]}


# Exactly TWO branches of the userInfo deser 0x18013ec10 have side effects OUTSIDE
# the userInfo record -- they reach into the global model singleton FUN_18011a830:
#   squadList(0x2d4)     -> vtbl[0x480] -> +0x30, fills the squad-ROSTER model
#   unopenedPacks(0x35e) -> vtbl[0x4e0](preOrderPacks + recoveredPacks)
# Every other member just fills a scalar/string slot in the record. The 2026-08-03
# crash is a NULL member inside a FIFA17.exe UI model, and userInfo had never
# actually reached the client before (massinfo was {} and /user is not called at
# boot) -- so these two newly-exercised branches are the prime suspects, and both
# are optional for coins/record. Ladder, cheapest-first:
#   min = ONLY what coins/record need | safe = + the rest of the scalars
#   roster (default) = +squadList (MY SQUADS) | packs = +unopenedPacks | full = both
# `roster` is live-confirmed working (hub + coins + record + MY SQUADS: 1). Only
# `unopenedPacks` remains untested, hence `full` is not the default.
#
# ROOT CAUSE, ISOLATED LIVE 2026-08-03 -- clubNameChangeAllowed(0x8f) must be FALSE.
# Two runs sent the IDENTICAL field set and differed only in that one bool:
#   true  -> client shows the FUT club-name prompt, then dies confirming it
#            (ACCESS_VIOLATION reading 0x0 at FIFA17.exe+0x71b8651, 4/4 runs)
#   false -> no prompt at all, hub loads, coins + record render
# Sending true advertises "a club-name change is available", pushing the client into
# a naming flow whose UI model we never populate. Neither side-effecting member
# (squadList / unopenedPacks) was involved -- both were already omitted in the
# crashing run. Keep this false unless the rename flow is actually implemented.
#
# 2026-08-03, LATER: the rename endpoint IS implemented now (club_rename_route
# below) and it is still NOT enough to make this bool safe -- see FUT_CLUB_RENAME.
_UI = os.environ.get("FUT_USERINFO", "roster")

# ---- FUT_CLUB_RENAME: the in-game rename experiment (DEFAULT OFF) -----------
# clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62.
#
# DEFAULT IS FALSE AND THAT IS THE PROVEN-GOOD BEHAVIOUR. Setting FUT_CLUB_RENAME=1
# is a ONE-SHOT EXPERIMENT, not a feature, and the expected outcome is still the
# 2026-08-03 crash. Do NOT read "we now serve a correct rename endpoint" as "the
# flag is safe": nothing the server sends is consumed anywhere in the crash path.
#
# The traced chain, all of it CLIENT-side after the bool:
#   userInfo+0x62 -> FUN_18001a7a0:  if rec[0x62]==1 { target="changeClubName";
#                                      state=0x3f } else { target=NULL; state=2 }
#   state 0x3f    -> FIFA17.exe front-end flow manager -> exe-side naming screen
#   on confirm    -> FUT-manager vtbl+0xa70 (name validation / profanity, component
#                    GUID 0xed84b11, fetched by FUN_180009c80)
#   ONLY THEN     -> CardsDLL builds the request (FUN_1800824b0) and sends it via
#                    vtbl+0x5d0  <-- the first point our endpoint could possibly
#                                    matter, and the crash happens BEFORE it.
# That matches the observed signature exactly: ACCESS_VIOLATION reading 0x0 at
# FIFA17.exe+0x71b8651, 4/4 runs, no CardsDLL frame on the stack, no HTTP request
# in flight. The crash site itself could NOT be identified statically -- FIFA17.exe
# is Denuvo-packed, RVA 0x71b8651 lands in the encrypted .data blob (file offset
# 0x3499e51, high entropy), and the reported bytes `49 8B 41 28 4C 8B 08` occur
# ZERO times in the whole 224 MB file. The leading theory (vtbl+0xa70 is an EA
# text-filter service that is NULL offline) is a HYPOTHESIS, not a finding.
#
# THE SAFE WAY TO RENAME YOUR CLUB IS OFFLINE, and it works today:
#     python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
#     ./openfut-fut.sh restart
# INSTANT FALLBACK if you do try the flag: `unset FUT_CLUB_RENAME`, restart, the
# hub is immediately back. Fixing it for real needs a live /proc/PID/mem dump
# around 0x1471b8651 (Wine maps the PE flat at 0x140000000) -- a separate task.
_CLUB_RENAME = os.environ.get("FUT_CLUB_RENAME") == "1"

# ---- FUT_CLUB_IDENTITY ladder ----------------------------------------------
# Which club/gamertag-identity bodies we serve. Same convention as FUT_MASSINFO /
# FUT_USERINFO: each rung is one more change class, so a live regression bisects
# in one step.
#   off      -> DEFAULT: pre-2026-08-03 behaviour, /clubUser and /user/list stub out
#   route    -> /clubUser serves {"user":[...]} and /user/list serves the club-info
#               body.
#   massinfo -> ALSO injects clubUser into the userMassInfo body. massinfo is
#               boot-critical and adding a member to it is exactly the change class
#               behind the last two live regressions; FUT_MASSINFO=squad remains the
#               instant known-good fallback.
#
# WHY `off` IS THE DEFAULT (corrected after review, 2026-08-03): this ladder was
# first shipped defaulting to `route` on the argument that these are "NEW ROUTES, so
# nothing that works today changes shape". The live log refutes the premise --
# EVERY request to /clubUser (22) and /user/list (39) in /tmp/utas_server.log is
# dated 21:xx, i.e. the contract suite hitting them. The real client, across five
# full sessions between 17:00 and 20:45, requested NEITHER. So populating them buys
# nothing observable and hands the client two response bodies it has never parsed --
# the exact change class that produced today's two live regressions. Turn a rung on
# only when the log shows the client actually asking.
_CLUB_ID = os.environ.get("FUT_CLUB_IDENTITY", "off")


def user_info():
    # Deserializer 0x18013EC10; every member optional (unknown key ids are
    # skipped via 0x180135FF0), so {} also parses.
    # Now that massinfo is populated, this record actually reaches the hub -- read
    # the live profile instead of the old hardcoded placeholders.
    p = STORE.profile()
    rec = p.get("record", {})
    info = {
        # Identity/club come from ACCOUNT, never from the save: the save is only a
        # mirror (fut_store._sync_identity) and must not be able to disagree with
        # what Blaze PDTL / LSX GetProfileResponse assert for the same session.
        "personaId": ACCOUNT.persona_id,
        "clubName": ACCOUNT.club_name, "clubAbbr": ACCOUNT.club_abbr,
        # established(0x110) is a STRING of digits: deser 0x18013ec10 case 0x110
        # takes the STRING getter then strtol base 10 into rec+0x64. An int on the
        # wire here is the scalar/string mismatch class that busy-loops the SAX
        # reader at 0x1801c7f1a. ACCOUNT.established is str-typed for this reason.
        "established": ACCOUNT.established,
        # accountCreatedPlatformName(0x6), stored at userInfo+0x42: the only string
        # 0x18013ec10 consumes that we used to omit (clubAbbr 0x8d / clubName 0x8e /
        # established 0x110 were already covered). Sending it is correct, but note
        # it was NOT the cause of the create-club crash -- that was still identical
        # with this field present. Value MUST match the auth body's
        # nucleusPersonaPlatform, which is why it reads the locked wire constant.
        "accountCreatedPlatformName": ACCOUNT.PLATFORM,
        # The userInfo currency-element parser FUN_180138bd0 reads name(0x1d0),
        # funds(0x134), finalFunds(0x124), active(0xa) -- there is NO "value" key,
        # so the old {"name","value"} pairs were parsed as 0 and the hub showed 0
        # coins. The caller then matches name against "coins"/"points" literally.
        "currencies": [
            {"name": "coins",  "funds": STORE.coins(),
             "finalFunds": STORE.coins(), "active": True},
            {"name": "points", "funds": p.get("points", 0),
             "finalFunds": p.get("points", 0), "active": True},
        ],
        # record: won(0x387) draw(0xe6) loss(0x1a6), all scalar slots in the record
        "won": rec.get("won", 0), "draw": rec.get("draw", 0), "loss": rec.get("loss", 0),
    }
    # Everything below is optional for coins/record. `min` stops here.
    if _UI != "min":
        info.update({
            # clubNameChangeAllowed(0x8f) -> bool at userInfo+0x62. FALSE unless
            # FUT_CLUB_RENAME=1: True is the isolated root cause of the 2026-08-03
            # create-club crash and serving the rename endpoint does NOT fix it
            # (the crash is upstream of the network). See the _CLUB_RENAME block.
            "clubNameChangeAllowed": _CLUB_RENAME,
            "divisionOffline": 10, "divisionOnline": 10,
            "purchased": False,                       # 0x262 -> bool at +0x68
            "feature": {"trade": True},
            "reliability": {"reliability": 100, "matchUnfinishedTime": 0},
            "bidTokens": {"count": 0, "updateTime": 0},
            "trophies": 0, "sessionCoinsBankBalance": 0,
            # actives(0xb): array of <=5 item refs (item parser 0x18013fe00). The seed
            # ladder squad has none yet -> empty array (arrays never desync).
            "actives": (current_squad().get("actives") or [])[:5],
        })
    # ---- the two side-effecting members, off by default (see _UI above) --------
    if _UI in ("packs", "full"):
        # unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
        # (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
        info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0}
    if _UI in ("roster", "full"):
        # squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
        # fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
        # Must be an OBJECT {"squad":[...]}; a bare [] is SKIP-safe but leaves the
        # roster empty. NOT required for the ACTIVE squad -- that comes from the
        # massinfo `squad` member, which is already proven working.
        info["squadList"] = squad_list_body()
    return info


# GET ut/game/<sku>/user parser 0x180146970 does Parse + TWO NextToken calls
# before deserializing -> the object MUST be wrapped in one member.  The member
# NAME is never compared, but the nesting level is required.
def user_get():
    return {"userInfo": user_info()}


# POST ut/game/<sku>/user (CreateUser, 0x18014CC60) recognises exactly:
# bonusPacks(0x5d) login(0x1a5) squad(0x2cd) starterPack(0x2e5) userData(0x36d).
def user_post(h=None):
    # CREATE-CLUB VARIANT: CardsDLL builder FUN_18014ca00 sends
    # {useFut1Data:false, clubName, clubAbbr, purchased:false} on this same URL.
    # Adopt the name the user typed so a create-club round-trips instead of the
    # client seeing its own choice silently replaced by ours. Same validation and
    # the same never-4xx rule as club_rename_route().
    if h is not None:
        _adopt_club_from_body(h, "CREATE-CLUB")
    # squad(0x2cd) goes to the SAME LoadActiveSquad parser as everywhere else, so
    # serve the real schema-correct squad rather than {} -- a create-club response
    # carrying an empty squad leaves the client with a 0-slot squad model, which is
    # precisely the state that makes AddPlayerToSquad no-op (see REBUILD_PLAN S9c).
    #
    # This body is FLAT and that is correct. Every key here is dispatched, including
    # the first one. Do not "fix" it by wrapping it in an envelope key: CreateUser's
    # ladder has arms for exactly these five atoms (login 0x1a5, userData 0x36d,
    # squad 0x2cd, starterPack 0x2e5, bonusPacks 0x5d) and nothing else, so a wrapper
    # name would hash to an atom with no arm and the whole object would be skipped.
    #
    # Why this note exists: an earlier pass on 2026-08-05 claimed the opposite, that
    # the three tokenizer calls before the key loop consume `{`, the first field name
    # and its value, so the first key was silently eaten. That was WRONG. The first
    # call to FUN_1801c7f10 returns token 7 and consumes NO input (once-only branch
    # guarded by the flag at parser+0xda), so the three tokens are BOF, `{`, and the
    # FIRST FIELD NAME. The loop dispatches from that first key onward. The `== 10`
    # test on the third token is just the empty-object early-out for `{}`.
    # Refuted live rather than on paper: GET /hub is the same flat shape, and its
    # first key clubPlayers read back as 205 out of the running client at
    # model+0x1fd70+0x3c, i.e. it reached its arm. Key order here is NOT load-bearing.
    # Token enum, from FUN_1801c67a0: 7=BOF 9=START_OBJECT 10=END_OBJECT 11=FIELD_NAME
    # 12=START_ARRAY 13=END_ARRAY. See docs/plan-2026-08-05-pack-opening.md section 2
    # and tools/ghidra_queries/q_envelope_{1,2,3}.py + q_hub_{1,2,3}.py.
    return {"login": True, "userData": user_info(),
            "squad": current_squad(), "starterPack": {}, "bonusPacks": []}


# ---- CLUB IDENTITY: the gamertag-carrying bodies ---------------------------
def club_user_body():
    """GET ut/%s/clubUser -- FutGetClubUsersServerResponse (deser 0x180145c00).

    THE BUG THIS FIXES: `/ut/game/fifa17/clubUser` used to be swallowed by the
    generic `(G + r"/club")` route (verified by running that compiled regex
    against the real path) and answered {"itemData":[...club items...]}. The
    GetClubUsers deser recognises ONLY `user`(0x36c) and SKIPs itemData, so the
    club-user model was empty BY CONSTRUCTION -- there was no network-supplied
    gamertag anywhere in FUT. docs/ENDPOINT_MAP.md row 2 recorded this as a GAP
    but described the old response as `{}`; it was actually the itemData body.

    SCHEMA: {"user":[element]}. Three scalars, all freeze-safe:
      persona  (0x21a) STRING, bounded-copied to 32 chars -> FUN_180008120(dst,s,0x21)
      personaId(0x21b) INT64
      public   (0x25f) BOOL
    None of them is an array/object slot, so the scalar-vs-container freeze class
    (busy-loop at 0x1801c7f1a) does not apply to this body at all.

    NOTE the element-parser address is recorded twice and inconsistently
    (0x180145480 in ENDPOINT_MAP row 2, 0x180138b00 in the display recon). Both
    derivations agree on the top-level key and on these three members, so it is a
    naming/wrapper question rather than a schema one -- carried as a caveat.
    """
    return {"user": [{
        "persona":   ACCOUNT.persona_name[:32],
        "personaId": ACCOUNT.persona_id,
        "public":    True,
    }]}


def club_info_body():
    """GET ut/%s/user/list -- the club-identity record list.

    DELIBERATELY NO `name` KEY: record+0x08 is filled by the merge FUN_18011e7c0,
    which matches on personaId and copies clubUser.persona in. That is exactly why
    personaId MUST be byte-identical here and in club_user_body() -- if they
    disagree the merge finds nothing and the name stays empty.

    OMITS squadList(0x2d4) ON PURPOSE: it is routed to FUN_180142260, and a bare
    array/scalar there is the 0x1801c7f1a busy-loop class. userInfo already carries
    the squadList via the FUT_USERINFO ladder, so there is nothing to gain here.
    """
    return {"user": [{
        "personaId":   ACCOUNT.persona_id,
        "clubName":    ACCOUNT.club_name,      # 0x8e
        "clubAbbr":    ACCOUNT.club_abbr,      # 0x8d
        "established": ACCOUNT.established,    # 0x110 -- STRING of digits
    }]}


def club_identity_route(kind):
    """FUT_CLUB_IDENTITY=off restores the pre-fix stubs for a one-step bisect."""
    if _CLUB_ID == "off":
        return 200, {}
    return 200, (club_user_body() if kind == "clubUser" else club_info_body())


# ---- ACCOUNTINFO (unproven; OFF by default) --------------------------------
# GET ut/%s/user/accountinfo is requested ~9x per session and we answer {}.
# It STAYS {} by default and that is a deliberate refusal, not an oversight: its
# parser is FutGetUserAccountInfoServerCallConfig, which lives inside the
# Denuvo-packed FIFA17.exe, so it CANNOT be reversed statically. Any key we invent
# has an unknown expected TYPE, and a scalar where a container is expected is
# precisely the freeze class this whole codebase is organised around avoiding.
# FUT_ACCOUNTINFO=1 serves a guessed body for a single deliberate experiment. Every
# key in it is a real atom from docs/fut_atoms.tsv and every value is a scalar --
# that bounds the risk, it does not eliminate it. Treat a freeze after enabling
# this as expected, and unset it.
_ACCOUNTINFO = os.environ.get("FUT_ACCOUNTINFO") == "1"


def accountinfo_body():
    if not _ACCOUNTINFO:
        return {}
    return {
        "userId":    ACCOUNT.user_id,        # 0x36f
        "personaId": ACCOUNT.persona_id,     # 0x21b
        "persona":   ACCOUNT.persona_name,   # 0x21a
        "name":      ACCOUNT.persona_name,   # 0x1d0
        "email":     ACCOUNT.email,          # 0xf8
        "country":   ACCOUNT.country,        # 0xbd
    }


# ---- CLUB RENAME -----------------------------------------------------------
def _adopt_club_from_body(h, tag):
    """Parse {clubName(0x8e), clubAbbr(0x8d)} out of a request body, validate it
    against the client's OWN limits, persist it, and mirror it into the save.

    Returns True if the club changed. NEVER raises and NEVER makes the caller
    answer 4xx -- see club_rename_route() for why that rule is load-bearing.
    """
    try:
        body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
    except Exception:
        body = {}
    if not isinstance(body, dict):
        return False
    name = body.get("clubName")
    abbr = body.get("clubAbbr")
    if name is None and abbr is None:
        return False
    name = ACCOUNT.club_name if name is None else name
    abbr = ACCOUNT.club_abbr if abbr is None else abbr
    if (name, abbr) == (ACCOUNT.club_name, ACCOUNT.club_abbr):
        return False
    try:
        validate_club(name, abbr)
    except ValueError as e:
        # Rejected: keep the old club, keep serving 200. The client is told the
        # transport succeeded and simply keeps rendering whatever userInfo says.
        log("    %s: REJECTED %r/%r -- %s" % (tag, name, abbr, e))
        return False
    old = (ACCOUNT.club_name, ACCOUNT.club_abbr)
    try:
        ACCOUNT.set_club(name, abbr)
        ACCOUNT.save()                       # -> tools/fut_account.json
        STORE.refresh_identity()             # -> mirror into fifa17_profile.json
    except Exception as e:
        log("    %s: FAILED to persist %r/%r (%s: %s)" % (tag, name, abbr, type(e).__name__, e))
        return False
    log("    %s: club %r/%r -> %r/%r (persisted)"
        % (tag, old[0], old[1], ACCOUNT.club_name, ACCOUNT.club_abbr))
    return True


def club_rename_route(h):
    """PUT the club rename -- FutChangeClubNameServerResponse.

    RESPONSE IS `{}`, AND THAT IS COMPLETE, NOT A STUB. The struct has ZERO atoms:
    vtable 0x18022cb58 slot +0x08 is 0x1801642c0, whose entire body is `return 1`
    (a shared no-op deserializer also used by ActivateCard and SignLoanPlayer).
    The HTTP body is fully ignored. The ONLY thing read is the transport result
    code at result+0x1c (handler FUN_1800829c0):
        0    -> SUCCESS   (client then copies the name into userInfo rec+0x20 and
                           the abbr into rec+0x3e via FUN_180007f80(rec+0x3e,4,...))
        0x1f -> PROFANITY
        else -> FAILED
    So: HTTP 200, no FUT error code, EVER.

    NEVER 4xx, even on a rejected name. CardsDLL's failure reporter FUN_18016cca0
    explicitly SKIPS the whole 'R4ER: DISCONNECTED' telemetry path when the status
    is 200 -- answering 4xx is how a bad rename turns into a disconnect.

    TWO URLs, BOTH ROUTED. docs/ENDPOINT_MAP.md row 3 derives PUT `ut/%s/club`.
    The rename recon derives `ut/%s/user` (request row 0x1802cba70, urlIdx 0xb ->
    template @0x18021e030) plus a per-response-class literal suffix "/club"
    (appender 0x18014c740: MOV RCX,RDX; LEA RDX,[0x180225124 "/club"]; JMP
    0x180008020) = `ut/game/fifa17/user/club`, and it validated that same urlIdx
    column against three independently-known live URLs. Since the response is a
    zero-atom ack, being wrong about which costs nothing -- so both are served and
    the live log settles it.

    REQUEST BODY: builder FUN_18014c590 emits exactly clubName + clubAbbr, nothing
    else.
    """
    if h.command in ("PUT", "POST"):
        _adopt_club_from_body(h, "RENAME")
        return 200, {}
    # GET on the rename URL is not a known endpoint; a parseable {} is the
    # cheapest correct answer (unknown keys are SKIP'd everywhere in FUT).
    return 200, {}


# GET ut/game/<sku>/settings (0x18013C6D0) recognises ONE key: configs (0xa2),
# an array of {type(0x354), value(0x377)}. The flag is not the JSON key: the
# client hashes the STRING VALUE of `type` through the atom hasher FUN_180180d00
# and switches on it, 42 arms wide. See ENDPOINT_MAP "FutGetSettingsServerResponse".
#
# WHY THIS IS NOT `{"configs": []}` ANY MORE. The applier FUN_18011dc50 is the only
# writer of the IS_* UI gate bytes and every line is `byte = (field == 1)`. The
# FutDataManagerImpl ctor never touches those bytes. So a flag we do not send is a
# gate that is never opened, and IS_FRIENDLY_SEASON_ENABLED / IS_DRAFT_MODE_ENABLED
# have never been sent by anything. That is a mechanism for the standing bug where
# Seasons refuses while making ZERO requests to any of the four servers.
#
# FREEZE SAFETY. `value`'s getter 0x1801c79d0 takes int/float/bool/string and
# coerces to int64, so a scalar cannot desync the token reader here. Ints are used
# below. Never put an object or an array in `value`.
#
# THE STORE FLAGS ARE RE-ASSERTED DELIBERATELY. storeEnabled/coinEnabled/... reach
# the client today through the BLAZE config store, not through here, and the store
# screen is live-proven working. Once a populated configs array makes the applier
# run, it writes EVERY gate byte from this struct, so omitting them could turn the
# working store off. Sending them as 1 pins them to the state they are already in.
#
# DEFAULT IS `off`, deliberately. Populating the array is what makes the applier
# run at all, and it then writes EVERY gate byte from this struct, including the
# ones behind screens that work today. The house rule is that a flag defaults to
# the live-proven value and nothing here has been in front of the game yet. Flip
# it for the test: `FUT_SETTINGS=gates ./openfut-fut.sh start` (the orchestrator
# runs the servers under its own environment, so an export is enough).
_SETTINGS_MODE = os.environ.get("FUT_SETTINGS", "off")

# Flags that are already live-proven ON via the Blaze store. Re-asserted so the
# applier cannot regress a working screen. Keep in sync with FUT_RS4_CONFIG.
_SETTINGS_KEEP = (
    "storeEnabled", "storeEnabled_JP", "coinEnabled", "coinEnabled_JP",
    "cardPackStoreEnabled", "cardPackStoreEnabled_JP", "pointsPackStoreEnabled",
    "tradingEnabled",
)

# The gates nothing has ever populated. These are the point of the exercise.
_SETTINGS_GATES = (
    "friendlySeasonsEnabled",        # [0x16] -> 0x1fd3a -> IS_FRIENDLY_SEASON_ENABLED
    "enableDraftMode",               # [0x17] -> 0x1fd3d -> IS_DRAFT_MODE_ENABLED
    "enableSinglePlayerDraftMode",   # [0x18] -> 0x1fd3e (shares its arm with
    "enableOfflineDraftMode",        #           enableOfflineDraftMode)
    "tournamentQuitEnabled",         # [0x20] -> 0x1fd3b -> IS_TOURNAMENT_QUIT_ENABLED
)

# NOT sent, and each for a reason:
#   enableObjectives / enableObjectivesAsManagerTasks -- their shared arm can only
#     CLEAR the field (`if (value == 0) field = 0`), so 1 is a no-op and 0 would
#     switch objectives OFF. Nothing to gain, something to lose.
#   clientKeepAliveResetTimeoutSec / getOperationTimeoutSec -- these do not set a
#     field, they reprogram client timers with value*1000.
#   itemDbVersion / checkServerDbVersion -- checkServerDbVersion makes the client
#     go read a server_db_version config; leave the DB-version path alone.
#   enableSquadBuildingSetsFeature -- a real atom with NO arm in this switch, so
#     it does nothing here whatever we send.

# The positive control. maximumTradePileSize lands in field [0] and feeds
# FUN_18011f380, and transfer-list capacity is READABLE IN GAME. Without it a null
# result is ambiguous between "the flags did not help" and "the configs array never
# reached the consumer". With it, those two look different.
# 77 on purpose: it has to be a number FUT would never pick by itself. 100 is the
# stock-looking transfer-list size, so reading "x/100" in game would prove nothing.
# The profile holds 0 listings, so a small cap cannot strand anything.
_SETTINGS_PROBE = (("maximumTradePileSize", 77),)


def _settings_body():
    """off  -> the historical {"configs": []} baseline.
       keep -> re-assert only the already-working flags, plus the control. Isolates
               "does populating configs at all change anything" from the new gates.
       gates-> keep, plus the gates nothing has ever sent. The actual experiment."""
    if _SETTINGS_MODE == "off":
        return {"configs": []}
    rows = [{"type": k, "value": 1} for k in _SETTINGS_KEEP]
    if _SETTINGS_MODE == "gates":
        rows += [{"type": k, "value": 1} for k in _SETTINGS_GATES]
    rows += [{"type": k, "value": v} for k, v in _SETTINGS_PROBE]
    return {"configs": rows}


SETTINGS = _settings_body()

# GET ut/game/<sku>/userMassInfo (GetUserMassInfo, deser FUN_180174630).
#
# CORRECTED 2026-08-03 (supersedes the old "MUST BE {}" note). Full decompile of
# 0x180174630 (/tmp/ghidra_fut/massinfo.txt): the body is a FLAT object whose
# top-level keys dispatch to
#     userInfo(0x370) -> 0x18013ec10          squad(0x2cd) -> 0x18013d1f0
#     settings(0x2bf) -> 0x18013c6d0          userData(0x36d) -> 0x180142470
#     clubUser(0x91), errors(0x10c), loanPlayers(0x19a), loanPlayerClientData(0x199),
#     pileSizeClientData(0x227); everything else SKIP'd via 0x180135ff0.
# There is NO "user" wrapper (the old note was wrong -- "user" only appears nested
# inside clubUser). The prologue (2 x NextToken before the key loop) is identical
# to 0x18014cc60, the proven-flat CreateUser parser.
#
# The historical freeze is now attributed to the SQUAD member: it was fed a squad
# object built before the exact schema was known (0x18013d1f0 was only reversed on
# 2026-08-03). Every field of user_info() type-checks against 0x18013ec10, and the
# one structurally wrong field -- squadList as a bare array -- is SKIP-safe rather
# than spin-inducing. So massinfo is now served POPULATED (like EA does), which is
# what delivers the squad roster to the client at boot.
#
# LIVE STATUS 2026-08-03: `full` PARSES fine (no freeze -- the client processed it
# and issued the next request), but the client then enters FUT club-creation and
# dies confirming the club name: reproducible ACCESS_VIOLATION reading 0x0 at
# FIFA17.exe+0x71b8651 (3/3 runs). That crash site is
#     mov rax,[r9+0x28] ; mov r9,[rax]   <- r9->field_28 is NULL, unguarded
# in FIFA17.exe's own UI code -- no CardsDLL frame on the stack and NO request is
# sent when it happens, so it is not a response being rejected. Bisecting which
# massinfo member triggers it, one relaunch per value:
# BISECT RESULT: `squad` alone reaches the hub AND made the client issue
# PUT /squad/0 (blocker solved) -- so the crash is in the `userInfo` member.
# `full` is restored now that userInfo omits its two side-effecting members by
# default (see _UI): that is what puts coins/record back on the hub.
# INSTANT FALLBACK if the crash returns: FUT_MASSINFO=squad (known-good).
_MI = os.environ.get("FUT_MASSINFO", "full")


# ---- pileSizeClientData: the MY CLUB counter --------------------------------
# LIVE EVIDENCE (2026-08-04): the user opened MY CLUB, the client fetched GET /club
# and DISPLAYED all 99 players -- and the MY CLUB counter still read 0. So that
# counter is NOT derived from the item list; it is a PILE SIZE, delivered
# separately. massinfo's pileSizeClientData(0x227) is that member and we have never
# sent it. Parser 0x18013adb0: {"entries":[{"key":<int>,"value":<int>}]} -- key and
# value BOTH read with the int getter 0x1801c79d0, and the parser IS skip-safe.
#
# The pile-id enum is not recoverable from the strings (the "club"/"tradepile"
# literals are just atom names in the alphabetical key table). So rather than guess:
#
#   FUT_PILESIZES=probe -> emit one entry per candidate key 0..15 with a UNIQUE
#     recognisable value (100+key). Whatever number MY CLUB then displays names the
#     club pile's key: 103 means key 3. One launch identifies the enum.
#   FUT_PILESIZES=1     -> emit the REAL counts once PILE_KEY_CLUB below is known.
#
# Default OFF: this adds a member to boot-critical massinfo. It is a documented
# member of that parser and carries only ints, so the risk is low -- but "low" is
# what I said about displayGroup before it froze the store, so it ships behind a flag.
_PILESIZES = os.environ.get("FUT_PILESIZES", "")
PILE_KEY_CLUB = int(os.environ.get("FUT_PILE_KEY_CLUB", "-1"))   # set once probed


def pile_size_body():
    """massinfo.pileSizeClientData -- see the note above."""
    if _PILESIZES == "probe":
        return {"entries": [{"key": k, "value": 100 + k} for k in range(16)]}
    counts = {
        "club":      len(STORE.items()),
        "purchased": len(STORE.purchased()),
        "tradepile": len(STORE.listings()),
    }
    if PILE_KEY_CLUB >= 0:
        return {"entries": [{"key": PILE_KEY_CLUB, "value": counts["club"]}]}
    # No verified key yet -> announce the club count on every candidate key. Crude,
    # but every value is truthful, so no pile can be told a wrong number.
    return {"entries": [{"key": k, "value": counts["club"]} for k in range(16)]}


def massinfo():
    if _MI == "empty":
        return {}                                     # old known-hub-reaching body
    if _MI == "squad":
        return {"squad": current_squad()}
    if _MI == "userinfo":
        return {"userInfo": user_info()}
    if _MI == "settings":
        return {"settings": SETTINGS}
    body = {"userInfo": user_info(),                  # squadList -> roster singleton
            "squad": current_squad(),                 # personaId == ACCOUNT.persona_id
            "settings": SETTINGS,
            "userData": {}}
    if _CLUB_ID == "massinfo":
        # clubUser(0x91) IS a recognised massinfo key (deser 0x180174630), so this
        # is schema-legal -- it is opt-in only because massinfo is the boot-critical
        # body and adding a member to it is the change class behind the last two
        # live regressions. Instant fallback: FUT_MASSINFO=squad.
        body["clubUser"] = club_user_body()
    if _PILESIZES:
        # pileSizeClientData(0x227) -> parser 0x18013adb0, int-only, skip-safe.
        # This is what the MY CLUB counter reads (see pile_size_body above).
        body["pileSizeClientData"] = pile_size_body()
    return body

# ---- FUT item-definition serving (wf_e41070d8) -------------------------------
# The card view-model 0x1800d7920 renders identity/rating/face from a RESOLVED
# record at item+0x10, filled by looking the resourceId up in the FUT item-def
# store. That store is network-filled; empty offline => generic cards. FIFA
# fetches definitions from ut/<sku>/item/resource, ut/<sku>/defid, and batch
# ut/<sku>/item?idList=<ids>. We serve them here (deser 0x18013fe00, same as items).
# resourceId = playerId | version<<24 ; assetId = resourceId & 0xffffff.
PLAYER_DEFS = {
    # assetId: (name, rating, position, nation, leagueId, teamid, [6 attrs])
    20801: ("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80]),
}


def item_def(rid):
    """Build one FUT item-definition for a requested resourceId."""
    if CONSUMABLES:
        # A consumable's definition is NOT a player's. Answering a consumable
        # resourceId with cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no
        # miss-fill -- i.e. plausible-looking garbage. Same trap as fut_store._item():
        # this route also hardcoded cardsubtypeid 0 / rareflag 1, and rareflag 1 on
        # subtype 219 renders Player Fitness as Squad Fitness (FUN_1801bfac0 case 5).
        # Gated so the player definition path is untouched by default.
        import fut_consumables
        d = fut_consumables.def_for(rid)
        if d is not None:
            return d
    asset = rid & 0xffffff
    name, rating, pos, nation, league, team, attrs = PLAYER_DEFS.get(
        asset, ("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70]))
    return {
        "id":                rid,
        "resourceId":        rid,
        "definitionId":      rid,
        "assetId":           asset,
        "cardassetid":       asset,
        "commodityId":       asset,
        "cardsubtypeid":     0,
        "cardType":          0,
        "itemType":          "player",
        "rareflag":          1,
        "rating":            rating,
        "preferredPosition": pos,
        "nation":            nation,
        "leagueId":          league,
        "teamid":            team,
        "playStyle":         250,
        "attributeList":     [{"index": i, "value": v} for i, v in enumerate(attrs)],
        "name":              name,
        "commonName":        name,
        "lastName":          name,
        "itemState":         "free",
        "untradeable":       True,
    }


def defs_route(h):
    # Parse every integer id out of the query string (idList=a,b,c / definitionId=x
    # / resourceId=x) and return a definition for each.
    q = h.path.split("?", 1)[1] if "?" in h.path else ""
    ids = [int(n) for n in re.findall(r"\d{3,}", q)]
    if not ids:
        return 200, {"itemData": []}
    return 200, {"itemData": [item_def(i) for i in ids]}


# ---- pack reveal: SOLVED 2026-08-04 -------------------------------------------
# The reveal hand-off worked live: five cards, Send to Club, session survived, cards
# persisted, no ut/delete/auth. See MOVE_BODY below for what actually fixed it.
#
# The workaround this block used to describe (deposit pack contents straight into
# the club at open time, keep the pending pile empty) is now DEFAULT OFF. It was
# always a cost, not a fix: with the pending pile empty the client has nothing to
# assign, so the reveal screen shows no cards AND the move request is never sent.
# That second effect made the real bug untestable -- the first live attempt at the
# correct response shape produced no PUT /item at all because autoclub had already
# emptied the pile. Leave this off unless the move path regresses.
PACK_AUTOCLUB = os.environ.get("FUT_PACK_AUTOCLUB", "0") == "1"

# FUT_MOVE_BODY -- what PUT ut/%s/item answers.
#   ack (default) -> {"itemData":[{"id":N,"pile":"club","success":true}, ...]}
#                    LIVE-PROVEN 2026-08-04. Five cards sent to club, session
#                    survived, cards persisted, NO ut/delete/auth logout.
#   empty         -> {}                                        known-broken
#   full          -> echo the moved card objects               known-broken
#   dreamsquads   -> {"itemData":[{"dreamSquads":[]} x N]}     known-broken
#
# WHY. 0x180128600 does not parse an acknowledgement, it builds per-item VERDICT
# records, and the completion handler raises EVENT_CARDS_MOVE_CARD_FAILURE when the
# record vector is EMPTY or when record+0x0c != 1. success(0x2fa) is initialised to
# '\0' per element. So {} and every echo shape reported the move as FAILED -- the
# session died because we told it to.
#
# The request the client actually sends (captured live, first time ever):
#   {"itemData":[{"id":100000125,"pile":"club","swap":0,"tradeId":0}, ...]}
# so `swap` and `tradeId` accompany id/pile. We ignore both; the move succeeded
# without honouring them.
#
# HISTORY, kept because the wrong version of it cost seven attempts. This file used
# to claim 0x180128600 had NO skip handler and parsed only itemData -> dreamSquads.
# Both false: two skip-handler sites, seven atoms. The claim came from searching a
# TRUNCATED decompile (src[:4000] of 6193 chars). It implied "the body cannot be the
# problem", which sent the investigation after client-side state. It was the body.
# See REBUILD_RESEARCH.md S16.
#
# Also retracted: the argument that a bare {} was "demonstrably acceptable" because
# Quick Sell survives one. Quick sell's callbacks read only the transport code and
# never touch the body, so its tolerance said nothing about this endpoint.
#
# Still true from that round: the netwatch recorded ZERO non-loopback connections
# during a failure, so "error connecting to FIFA 17 Ultimate Team" is FIFA's generic
# FUT-session failure text and must never be read as a network event.
MOVE_BODY = os.environ.get("FUT_MOVE_BODY", "ack")

# FUT_STORE_GROUPS and FUT_STORE_FIELDS ARE GONE. Both were kept around as "maybe
# they were nearly right" experiments; the 2026-08-04 read of 0x18013af30 (19,279
# chars, read in full by two independent agents) showed both were wrong in ways that
# make them unsafe to keep even switched off:
#   STORE_GROUPS sent displayGroup as an ARRAY. It is a flat OBJECT of two members.
#   STORE_FIELDS sent actionType(0x8) and firstPartyStoreId(0x127) as INTEGERS. Both
#     take the STRING getter 0x1801c7aa0.
# Each is the type-desync freeze, so leaving them in the file as togglable options was
# leaving two loaded guns on the table. The fix that replaces them is below.
#
# FUT_STORE_DISPLAYGROUP: send the ONE key that actually names a tile,
# displayGroup(0xd9) as {"value": "<pack name>"}. DEFAULT ON since 2026-08-04: it was
# shipped off because the parser-side proof did not cover whether the key switches
# FIFA17.exe to a different (packed, unreadable) tile render path. It was then run live
# with FUT_STORE_DISPLAYGROUP=1 and the store tiles rendered their real names instead of
# "unknown", so the live-proven value is now on. See _pack_body.
STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"

# FUT_STORE_GROUPID: give each pack a DISTINCT displayGroupAssetId so the grouped
# layout that FUT_STORE_DISPLAYGROUP switched on has something to separate packs by.
# Default off. See the long note at the send site in _pack_body.
STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"


# FUT_QUICKSELL: serve the SINGLE-CARD quick sell, which we have never served.
#
# CAPTURED LIVE 2026-08-05 19:39:52. The client sends:
#     DELETE /ut/game/fifa17/item/100000240      (no body, id in the URL)
# ENDPOINT_MAP documented the path as `ut/delete/game/%s/item`, and ROUTES was built
# from the doc, so the regex at the bottom of this file has never matched a real quick
# sell. Every quick sell to date fell through to the catch-all. quick_sell_route()
# below, and STORE.quick_sell() behind it, have therefore never once been called.
#
# WHAT THE EMPTY RESPONSE DID. Answering {} does not merely skip the credit. The
# client takes its coin balance from this response, so with totalCredits absent it
# rendered an uninitialised value: a real session showed 1,133,686,384 coins against a
# true balance of 9,889,600. It is only a display artifact, corrected by the next
# GET /user/credits, and the save was never touched. But it means this response is
# BALANCE-BEARING and cannot be stubbed.
#
# WHAT IS STILL UNKNOWN, and what the next live run settles. We do not know whether
# the client ASSIGNS totalCredits as the new balance or ADDS it as a delta. One garbage
# sample cannot distinguish them. We send the NEW BALANCE, which is the natural reading
# of the field name, and the run is self-diagnosing:
#     balance shows old + value   -> assign. Correct, keep it.
#     balance shows roughly double -> delta. Send (value) instead of (new balance).
# The coin figures are large enough that doubling is unmistakable.
#
# The credit itself is STORE.quick_sell()'s rating-based fallback, which is an invented
# number, not FUT's real discard table. That table is still UNKNOWN. Flagged here so
# nobody mistakes it for a reversed value.
# DEFAULT ON since 2026-08-05: live-proven. Two quick sells fired through this
# handler in one session, each credited 150 and removed the card, and the coin
# arithmetic reconciled exactly against the pack purchases either side of them.
# The previous behaviour (unmapped -> {}) is strictly worse: it credited nothing
# and left an uninitialised balance on screen.
QUICKSELL = os.environ.get("FUT_QUICKSELL", "1") == "1"


def quick_sell_url_route(h):
    """DELETE ut/%s/item/<id> -- single-card Quick Sell, the real wire form.

    Default OFF returns exactly what the catch-all returned before, so the baseline
    the client has always seen is unchanged until this has been in front of the game.
    """
    m = re.search(r"/item/(\d+)", h.path)
    if h.command != "DELETE" or not m:
        return 200, {}
    if not QUICKSELL:
        log("    QUICKSELL: id=%s seen, handler DISABLED (FUT_QUICKSELL=0), "
            "answering {} as before" % m.group(1))
        return 200, {}
    iid = int(m.group(1))
    sold, coins = STORE.quick_sell([iid])
    if not sold:
        # Do not claim to have sold a card we cannot account for. An invented verdict
        # desyncs the client's model against ours, which is worse than an honest miss.
        log("    QUICKSELL: id=%d NOT FOUND in either pile, no credit" % iid)
        return 200, {"items": [], "totalCredits": STORE.coins()}
    total = STORE.coins()
    log("    QUICKSELL: sold id=%d for %d coins -> balance %d" % (iid, coins, total))
    # FutDiscardCardServerResponse: items is an array of OBJECTS and there is no
    # top-level id. Bare ints here would be a type desync, i.e. a freeze.
    return 200, {"items": [{"id": iid}], "totalCredits": total}


def quick_sell_route(h):
    """POST ut/delete/%s/item -- Quick Sell (the reveal screen's 'Quick Sell All').

    Discovered live 2026-08-04 as an UNMAPPED path. The bare {} it was getting is
    ACCEPTED by the client (unlike the move path), but nothing was credited, so a
    quick sell destroyed the cards for 0 coins.

    Coin value: FUT quick-sell pays the card's discardValue. Ours are seeded 0, so
    fall back to a rating-based figure in the same spirit as the market pricing
    heuristic -- an invented number, but a sane one, and better than zero. The client
    re-reads the balance from GET /user/credits straight after (observed), so the
    response body itself only has to be accepted."""
    try:
        body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
    except Exception:
        body = {}
    ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)]
    if not ids and isinstance(body.get("itemIds"), list):
        ids = body["itemIds"]
    sold, coins = STORE.quick_sell(ids)
    if sold:
        log("    QUICKSELL: sold %d card(s) for %d coins (total %d)"
            % (sold, coins, STORE.coins()))
    return 200, {}


def _move_ack(req, moved):
    """FutMoveCard per-item verdict records (deser 0x180128600).

    Built from the REQUEST, not from `moved`: under FUT_PACK_AUTOCLUB the pack's
    cards are already in the club before the client asks to move them, so
    STORE.move_items() legitimately returns nothing and a `moved`-derived body
    would be empty -- which is the one shape guaranteed to raise
    EVENT_CARDS_MOVE_CARD_FAILURE.

    success is asserted ONLY for ids we can actually account for: either this call
    moved them, or they are already sitting in the club. Anything else gets
    success:false, which is the truthful verdict -- claiming success for an id the
    store has never seen would desync the client's model against ours, and that is
    a worse failure than an honest per-item false.

    `reason`(0x279) is omitted deliberately. The only string known to map to a
    specific code is "Destination Full" (0xf) and that is not what happened here;
    the key is skip-safe, so sending nothing is better than sending a wrong reason.
    """
    club_ids = {it.get("id") for it in STORE.items()}
    moved_ids = {it.get("id") for it in moved}
    out = []
    for r in req:
        if not isinstance(r, dict):
            continue
        iid = r.get("id")
        ok = iid in moved_ids or iid in club_ids
        out.append({
            "id": iid,                              # INT getter -- send a number
            "pile": r.get("pile", "club"),          # STRING -> enum 0x180142650
            "success": bool(ok),                    # BOOL, record+0x0c, must be 1
        })
    if out and not all(x["success"] for x in out):
        log("    ITEM: %d/%d verdicts are success=false (ids not accounted for)"
            % (sum(1 for x in out if not x["success"]), len(out)))
    return out


def item_route(h):
    # PUT ut/game/fifa17/item = FutMoveCard (move item to a pile, e.g. the reveal
    # screen's "keep/assign" -> {"itemData":[{"id":..,"pile":"club","swap":0,
    # "tradeId":0}]}). Response echoes the FULL updated card-item(s) + chemistry
    # bool (ENDPOINT_MAP item rows 6/7/10/11; itemData via deser 0x18013fe00).
    # Returning [] here makes FIFA think the move failed -> kicks to main menu.
    # GET stays defs_route (ViewCards / ConsumablesSearch / loan lists).
    if h.command == "PUT":
        try:
            body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
        except Exception:
            body = {}
        req = body.get("itemData")
        if isinstance(req, list):
            moved = STORE.move_items(req)
            # ack is answered BEFORE the `if moved:` gate on purpose. Under
            # FUT_PACK_AUTOCLUB=1 (the default) a pack's cards are already in the
            # club by the time the reveal screen asks to move them, so move_items()
            # legitimately returns nothing -- and gating the ack on `moved` would
            # emit a ZERO-RECORD itemData, which is precisely the shape that raises
            # EVENT_CARDS_MOVE_CARD_FAILURE. The verdict has to come from the
            # REQUEST. (Caught in review before it ever ran; the earlier placement
            # would have made ack mode fail in exactly the configuration it is meant
            # to fix, and the bug would have looked like "ack does not work".)
            if MOVE_BODY == "ack":
                if moved:
                    log("    ITEM: moved %d item(s) to pile(s)" % len(moved))
                return 200, {"itemData": _move_ack(req, moved)}
            if moved:
                log("    ITEM: moved %d item(s) to pile(s)" % len(moved))
                # RETRACTED 2026-08-04. This block previously claimed:
                #   "FutMoveCard 0x180128600 HAS NO SKIP HANDLER ... calls it ZERO
                #    times ... parses exactly two atoms, itemData and dreamSquads"
                # That was WRONG and it sent the investigation after client-side
                # state for seven attempts. Cause of the error: the decompile was
                # truncated to 4000 chars before being searched, and the function is
                # 6193 chars -- BOTH FUN_180135ff0 call sites (offsets 5006 and 6080)
                # and four of the seven atoms lie past the cut. Never conclude an
                # absence from a truncated decompile.
                #
                # VERIFIED SHAPE (full decompile, getter read from the first call
                # after each atom compare):
                #   itemData(0x16b)  array of PER-ITEM VERDICT RECORDS, 0x18 bytes each
                #     id(0x15c)      INT     0x1801c79d0     -> record+0x00
                #     pile(0x226)    STRING  0x1801c7aa0     -> enum via 0x180142650
                #                                              club=7 purchased=6 trade=5
                #     success(0x2fa) BOOL    0x1801c7620     -> record+0x0c
                #     reason(0x279)  STRING  0x1801c7aa0     -> "Destination Full" = 0xf
                #     dreamSquads(0xe9) INT array
                #     anything else  -> FUN_180135ff0 (the skip handler, twice)
                #
                # This is NOT an ack endpoint. The completion handler raises
                # EVENT_CARDS_MOVE_CARD_FAILURE when the record vector is EMPTY or
                # when record+0x0c != 1, and `success` is initialised to '\0' at the
                # top of every element loop. So every body we have ever returned --
                # full cards, +chemistry, dreamsquads-only, and `empty` ({}, which
                # produces zero records and fails the first guard outright) -- has
                # reported the move as FAILED. Quick sell survives an identical {}
                # because its callbacks check only the transport code and ignore the
                # body: that is the whole asymmetry, and it was on the wire after all.
                #
                # `ack` is the corrected shape. NOT the default yet: it is untested
                # live, and the necessary condition being identified is not proof of
                # sufficiency. One launch with FUT_MOVE_BODY=ack settles it.
                if MOVE_BODY == "full":
                    return 200, {"itemData": moved}
                if MOVE_BODY == "dreamsquads":
                    return 200, {"itemData": [{"dreamSquads": []} for _ in moved]}
                return 200, {}
        # Nothing matched (ids not in the pending pile). Answer in the SAME shape as
        # a successful move so the client cannot tell the two apart structurally.
        return 200, ({} if MOVE_BODY == "empty" else {"itemData": []})
    return defs_route(h)


G = r"/ut/game/[^/]+"
ROUTES = [
    # ---- FUT item-definition endpoints (must precede generic /item, /user) ----
    (re.compile(G + r"/item/resource"),              lambda m, h: defs_route(h)),
    (re.compile(G + r"/defid"),                      lambda m, h: defs_route(h)),
    # DELETE ut/%s/item/<id> -- single-card Quick Sell. Captured live 2026-08-05.
    # Disjoint from the /item(\?|$) move route below (that one cannot match a path
    # with a trailing /<id>), but kept above it so the item routes read in one block.
    (re.compile(G + r"/item/\d+"),                   lambda m, h: quick_sell_url_route(h)),
    (re.compile(G + r"/item(\?|$)"),                 lambda m, h: item_route(h)),
    # ---- store / packs (match regardless of /ut/game vs /ut/v2/game prefix) ----
    (re.compile(r"/store/purchasegroup"),            lambda m, h: store_catalog(h)),
    (re.compile(r"/store/transaction"),              lambda m, h: store_buy(h)),
    # ut/v2/game/fifa17/store = FutStorePackQuantities ELIGIBILITY GATE, not a
    # quantity list. deser 0x1801758c0 reads one key "result" (atom 0x288); the
    # store screen shows "not available" unless this is SUCCESS. (ENDPOINT_MAP
    # store §2.) Bare /store only -- purchasegroup/transaction matched above.
    (re.compile(r"/store(\?|$)"),                    lambda m, h: (200, {"result": "SUCCESS"})),
    (re.compile(r"/purchased"),                      lambda m, h: purchased_items(h)),
    # POST ut/auth: the response is unchanged; the REQUEST body is now read so
    # ACCOUNT can adopt the persona the client itself asserts (see auth_body).
    (re.compile(r"^/ut/auth"),                       lambda m, h: (200, auth_body(h))),
    (re.compile(r"^/ut/delete/auth"),                lambda m, h: (200, {})),
    (re.compile(G + r"/settings"),                   lambda m, h: (200, SETTINGS)),
    # Device-trust ("phishing") flow. trusteddevice parser 0x18012a170 reads 4
    # booleans by key-id 0x7e/0x117/0x19e/0x351; 0x351 == JSON key "trusted".
    # Returning trusted=true makes FUT SKIP the security question.
    (re.compile(G + r"/phishing/trusteddevice"),     lambda m, h: (200, {"trusted": True})),
    (re.compile(G + r"/phishing/validate"),          lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
    (re.compile(G + r"/phishing/question"),          lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
    (re.compile(G + r"/user/credits"),               lambda m, h: credits_route(h)),
    # ---- club/squad routes (2026-08-03: squad schema 0x18013d1f0 now reversed) ----
    # /user, /squad and /userMassInfo serve real data again -- the squad object
    # matches the verified schema, so the 2026-08-01 revert-to-{} no longer applies.
    #
    # ORDER MATTERS TWICE HERE:
    #  * /clubUser MUST precede the generic /club route at the bottom of this table,
    #    which was silently swallowing it and answering with the club ITEM list.
    #  * /user/club MUST precede /user/list and /user$ so the rename URL is not
    #    absorbed by a neighbour. (It would otherwise fall through to /club, which
    #    now also dispatches renames -- but relying on that is a trap for the next
    #    edit of this table.)
    (re.compile(G + r"/clubUser"),                   lambda m, h: club_identity_route("clubUser")),
    (re.compile(G + r"/user/club"),                  lambda m, h: club_rename_route(h)),
    (re.compile(G + r"/user/list"),                  lambda m, h: club_identity_route("userList")),
    (re.compile(G + r"/user/accountinfo"),           lambda m, h: (200, accountinfo_body())),
    (re.compile(G + r"/user$|" + G + r"/user\?"),    lambda m, h: user_route(h)),
    # LIVE GROUND TRUTH 2026-08-03: FutSquadList has its OWN URL, `ut/%s/squad/list`
    # -- the request-table strings only showed ut/%s/squad, so the static conclusion
    # "there is NO separate squad-list URL" (REBUILD_PLAN S1b) was WRONG. This must
    # precede the generic /squad route, which was swallowing it and returning the
    # full active-squad object; the list parser 0x180142260 recognises ONLY
    # squad(0x2cd) and skipped every one of those keys -> "MY SQUADS: 0".
    (re.compile(G + r"/squad/list"),                 lambda m, h: (200, squad_list_body())),
    # Same bug as /squad/list, second instance: `ut/%s/squad/mode` + `/draft/state`
    # is composed by appending a suffix, so it is invisible to the request-template
    # table, and the generic /squad route below was swallowing it. MUST precede it.
    (re.compile(G + r"/squad/mode/draft/state"),     lambda m, h: draft_state_route(h)),
    (re.compile(G + r"/purchase/mode/\d+/draft"),    lambda m, h: draft_purchase_route(h)),
    (re.compile(G + r"/squad"),                      lambda m, h: squad_route(h)),
    (re.compile(G + r"/match/keepalive"),            lambda m, h: (204, None)),
    # PUT ut/%s/match/reset = FutResetMatch. Seen live at boot (2026-08-03) as an
    # UNMAPPED catch-all; it is a Tier-B ack (shared no-op deser), so {} is correct
    # -- routed explicitly so it stops showing up as an unmapped hit in the log.
    (re.compile(G + r"/match/reset"),                 lambda m, h: (200, {})),
    # THE CORE LOOP. ut/%s/match was the biggest hole in the API surface: we only
    # answered keepalive/reset, so the base endpoint fell through to the catch-all
    # {} and a finished match awarded NOTHING. Must precede any generic route.
    # ut/delete/%s/match/{id} is the DELETE form (UTAS tunnels DELETE through a
    # /ut/delete/ path prefix, same as trade/watchList/squad).
    (re.compile(r"/ut/delete/game/[^/]+/match"),     lambda m, h: match_route(h)),
    (re.compile(G + r"/match"),                       lambda m, h: match_route(h)),
    (re.compile(G + r"/hub"),                        lambda m, h: (200, hub_data())),
    # Populated massinfo (see massinfo() above): userInfo + squad + settings.
    (re.compile(G + r"/userMassInfo"),               lambda m, h: (200, massinfo())),
    # ---- game modes (FUT_MODES=1; default keeps the proven {} everywhere) ----
    # Order matters: the more specific season/tournament sub-paths must precede
    # the bare ones, and /leaderboards/options precedes /leaderboards.
    (re.compile(G + r"/season/\d+/reset"),           lambda m, h: (200, {"reset": True} if _MODES else {})),
    (re.compile(G + r"/season/user"),                lambda m, h: (200, season_user() if (_MODES and h.command == "GET") else {})),
    (re.compile(G + r"/season/friendly"),            lambda m, h: (200, {})),
    (re.compile(G + r"/season"),                     lambda m, h: (200, season_list() if (_MODES and h.command == "GET") else {})),
    (re.compile(r"/ut/delete/game/[^/]+/tournament"), lambda m, h: (200, {})),
    (re.compile(G + r"/tournament/user"),            lambda m, h: (200, tournament_user() if (_MODES and h.command == "GET") else {})),
    (re.compile(G + r"/tournament"),                 lambda m, h: (200, tournament_list() if (_MODES and h.command == "GET") else {})),
    (re.compile(G + r"/leaderboards"),               lambda m, h: leaderboard_route(h) if _MODES else (200, {})),
    (re.compile(G + r"/champion"),                   lambda m, h: champion_route(h) if _MODES else (200, {})),
    # FutGetCaptcha 0x18014e78d: encodedImg(str b64) sequence(int) sizeBeforeEncode(int).
    # Served always -- an empty captcha is strictly better than the catch-all {},
    # and all three fields are scalars (no freeze risk).
    (re.compile(G + r"/captcha"),                    lambda m, h: (200, {"encodedImg": "", "sequence": 0, "sizeBeforeEncode": 0})),
    (re.compile(G + r"/tfa"),                        lambda m, h: (200, {})),
    (re.compile(G + r"/clientdata"),                 lambda m, h: clientdata_route(h)),
    (re.compile(G + r"/livemessage"),                lambda m, h: (200, {})),
    (re.compile(G + r"/activeMessage"),              lambda m, h: (200, {})),
    # ---- transfer market / auction house (empty-but-valid; ENDPOINT_MAP market §)
    # tradePile MUST precede /trade ("/tradePile" contains the "/trade" prefix).
    (re.compile(G + r"/tradePile"),                  lambda m, h: tradepile_route(h)),
    (re.compile(G + r"/trade"),                      lambda m, h: trade_route(h)),
    (re.compile(G + r"/watchList"),                  lambda m, h: watchlist_route(h)),
    (re.compile(G + r"/auctionhouse"),               lambda m, h: auctionhouse_route(h)),
    # LIVE GROUND TRUTH: FIFA's market SEARCH hits /transfermarket (one word), not
    # /auctionhouse (was UNMAPPED -> {} => empty market). Serve the same listings.
    (re.compile(G + r"/transfermarket"),             lambda m, h: auctionhouse_route(h)),
    (re.compile(G + r"/marketdata"),                 lambda m, h: (200, {"minPrice": 150, "maxPrice": 15000})),
    # QUICK SELL. Live-observed 2026-08-04: the reveal screen's "Quick Sell All"
    # sends POST ut/delete/%s/item -- it was UNMAPPED (catch-all {}), which the
    # client ACCEPTS (no error, session survives) but which paid 0 coins: the user
    # sold 6 cards for nothing. Credit discardValue per card and remove them.
    (re.compile(r"/ut/delete/game/[^/]+/item"),      lambda m, h: quick_sell_route(h)),
    (re.compile(r"/ut/delete/game/[^/]+/trade"),     lambda m, h: delete_trade_route(h)),
    (re.compile(r"/ut/delete/game/[^/]+/watchList"), lambda m, h: (200, {})),
    # Generic /club, LAST on purpose (it is a prefix of /clubUser).
    #   PUT -> ChangeClubName, per docs/ENDPOINT_MAP.md row 3 (the other of the two
    #          competing URL derivations; see club_rename_route).
    #   GET -> the club item list, unchanged. NOT switched to GetClubInfo's `user`
    #          shape: its element parser 0x18012c990 is only PARTIALLY decoded, and
    #          the rendered club cards come through /item (ViewCards) anyway.
    # LIVE-OBSERVED, UNDOCUMENTED (found 2026-08-04 by replaying every path in
    # /tmp/utas_server.log): the client really fetches ut/%s/club/stats/{consumables,
    # staff,year} on the MY CLUB screen. They are suffix endpoints the request table
    # never lists -- the same trap as /squad/list. They were being swallowed by the
    # generic /club route, which answers with the FULL 28-item club list where the
    # client asked for STATS: wrong shape, and re-sent on every poll.
    (re.compile(G + r"/club/stats"),                 lambda m, h: club_stats_route(h)),
    # MUST precede the generic /club: the client asks GET club/consumables/<category>
    # and that path is a /club prefix, so without this line it is answered with the
    # player list. LIVE 2026-08-05: it was, and the consumables screen was handed
    # Cristiano Ronaldo when it asked for training cards.
    (re.compile(G + r"/club/consumables"),           lambda m, h: club_consumables_route(h)),
    (re.compile(G + r"/club"),                       lambda m, h: club_route(h)),
]


# ---- GET ut/%s/hub : THE MY CLUB TILE COUNTER --------------------------------
# FutGetHubDataServerResponse. Body parser FUN_180139610 (14,855 chars, censused in
# full: 18 atom comparisons, none missed). Root container is a FLAT OBJECT, verified
# from the prologue by tokenizer-call calibration against the live-proven massinfo
# parser rather than assumed.
#
# THE CHAIN, end to end, re-derived independently by two agents (one via Ghidra, one
# via raw PE plus capstone with no decompiler) and checked by two reviewers:
#
#   clubPlayers (atom 0x90)  --INT getter 0x1801c79d0--> clamp FUN_1800d7b30 (<=0 -> 0)
#     -> stored at R+0x3c, where R = FUT data-manager slot +0x1f8 (FUN_18011a810 is
#        literally `lea rax,[rcx+0x1fd70]; ret`)
#     -> read by FUN_1800b0250 and published as TEXT0 of TILE_ID 0x210
#     -> captions FUT_GH_TOTAL_PLAYERS_0 / _1 at 0x18020a0f8 / 0x18020a110
#   auctionCount (atom 0x33) -> R+0x38 -> TEXT0 of TILE_ID 0x1b0, the TRANSFERS tile
#
# `FUN_180139610` is the ONLY writer of +0x3c anywhere in the image (one write in
# 14,855 chars, guarded by `if (iVar6 != 0x90)`). So this is not a candidate, it is the
# field.
#
# WHY IT TOOK SO LONG, recorded because the error is instructive. The hunt went to
# /club/stats and stayed there for a day, and REBUILD_RESEARCH S19 concluded the
# counter was "not server-fixable" with a mechanism that was internally correct and
# entirely beside the point: the tile never read the club-stat store. Two things
# reinforced the wrong path. ENDPOINT_MAP said this response "uses C++ reflection /
# vtable dispatch, NOT an inline atom ladder, no static field ladder to read" and
# marked it a GAP, which is wrong: there IS an inline ladder, one indirection away.
# And the eight-row MY CLUB panel was assumed to be FUN_180043b90 case 1, which
# publishes six keys; it is actually FUN_180094ce0, a different provider using a
# different string family (FUT_MYCLUB_*), which reads neither the mode tag nor any
# type id we were sending. Two providers, and we were reading the wrong one.
#
# auctionCount is deliberately included as a FREE CONTROL: it lands in a different
# field (+0x38) and a different tile, so if the MY CLUB tile moves and TRANSFERS does
# not, the delivery is fine and something is specific to +0x3c.
#
# DEFAULT ON. Freeze risk is genuinely low rather than merely believed low: the body
# is a flat object of two integers, both read with the INT getter, so there is no
# array, no nested object, and no type-desync surface. FUT_HUBDATA=0 restores {}.
HUBDATA = os.environ.get("FUT_HUBDATA", "1") == "1"


def _is_player(it):
    """The CLIENT's own definition of a footballer, and the only one worth using.

    FUN_1800d8330 maps cardsubtypeid 0..3 -> cardtype 1 (players); 4 is a manager,
    5/6/7/8 the four coach families, 51..341 the consumables. Three counters used to
    ask `itemType == "player"` instead -- the hub's clubPlayers tile, the MY CLUB
    per-nation/league/team buckets and the global stat set. That string is INERT on
    the wire (atom 0x173 is parsed into a stack std::string in FUN_18013fe00 and
    freed; it never reaches the record), so keying our own screens on it made their
    correctness depend on a field the client ignores. Provable no-op on the current
    save: all 194 items are cardsubtypeid 0 and itemType "player"."""
    return it.get("cardsubtypeid", 0) in (0, 1, 2, 3)


def hub_data():
    """GET ut/%s/hub -- the FUT hub tile counters."""
    if not HUBDATA:
        return {}
    players = len([i for i in STORE.items() if _is_player(i)])
    auctions = len(STORE.listings())
    log("    HUB: clubPlayers=%d auctionCount=%d" % (players, auctions))
    return {"clubPlayers": players, "auctionCount": auctions}


# ---- club stats: the CLUB STATS panel, and probably the MY CLUB tile too ------
# GET ut/%s/club/stats/<mode> -> FutStickerBookStats2ServerResponse, deser
# 0x180130150 (7,870 chars, read end to end). Wire schema, fully verified:
#
#     {"stat":[{"contextId":int, "contextValue":int, "type":str, "typeValue":int}]}
#       contextId(0xb6)    INT    0x1801c79d0
#       contextValue(0xb7) INT    0x1801c79d0
#       type(0x354)        STRING 0x1801c7aa0   -> copied into a 0x30-byte buffer
#       typeValue(0x355)   INT    0x1801c79d0
#     Unknown keys route to FUN_180135ff0 at BOTH nesting levels, so extras are inert.
#
# FIVE THINGS THAT DECIDE WHETHER THIS WORKS, all learned the hard way:
#
# 1. EVERY RESPONSE WIPES THE WHOLE MAP FIRST. Nothing accumulates. So a good body on
#    one mode followed by a thin body on another ERASES the first, and the ordering of
#    the client's requests would decide what survives. The fix is to serve the SAME
#    COMPLETE SET for every Stats2 mode: then whichever request lands last leaves the
#    map correct and ordering stops mattering. (One investigator reported this factory
#    does NOT wipe; a reviewer re-read it and refuted that. The wipe is real.)
# 2. /club/stats/staff IS A DIFFERENT CLASS. It is FutStaffBonus, shape
#    {"bonus":[{"type":str,"value":int}]}, NOT Stats2. Sending a {"stat":[...]} body
#    there is harmless but does nothing. Its type strings are not decoded, so it keeps
#    {} -- which is safe, because that parser's top-level loop exits immediately on
#    END_OBJECT. It also means staff does NOT wipe the Stats2 map.
# 3. ELEMENT-LOCAL VARIABLES ARE NOT RESET BETWEEN ELEMENTS. The clears happen once
#    before the array loop, not inside it, so omitting a key in element N silently
#    inherits element N-1's value. ALWAYS EMIT ALL FOUR KEYS IN EVERY ELEMENT.
# 4. The storage key is contextValue ALONE. contextId is only a guard: contextId == 1
#    or 5 <= contextId <= 9 forces contextValue to 0, which is the global bucket the
#    +0x800 getter reads. We use contextId 1 throughout to land everything there.
# 5. Three ids the panel READS can never be SET from here: 0x3d CONTRACTS,
#    0x3e TRAINING, 0x40 FITNESS. No type string produces them.
#
# type string -> internal id -> the on-screen row it moves (FUN_18012fd40 -> the club
# stats provider FUN_180043b90):
#     players 1 PLAYERS          rarePlayers 5             staff 0xa STAFF_EMPLOYED
#     stadia 0x14 STADIA_OWNED   balls 0x1e BALLS_EARNED   kits 0x28 KITS_AVAILABLE
#     badges 0x2d BADGES         trophies 0x32 TROPHIES_WON
#
# WHY THIS IS NOW ALSO THE HUB-TILE CANDIDATE. The investigation concluded the MY CLUB
# tile does not read this store, but flagged that negative as BOUNDED: the interface
# comes through a QueryInterface adapter, so the vtable is assembled at runtime and
# cannot be read statically. Live evidence on 2026-08-04 points the other way. The hub
# tile reads "0 TOTAL PLAYERS" and the CLUB STATS panel reads "Players 0", the same
# quantity, both zero, while we answer {}. And FUT_CLUB_PAGE ruled out the alternative:
# we served 114 items to /club and the tile still said 0, so it is not a count of the
# list. Strong inference, not proof. This flag is the test.
#
# The test is unusually clean because the club holds 114 items and ALL of them are
# players: every other row is an honest zero. So if this works, exactly two numbers
# move (Players and Rare Players, 0 -> 114) and nothing else changes.
# DEFAULT ON since 2026-08-04: LIVE-PROVEN. With this serving, the MY CLUB ->
# ENGLAND -> Premier League row went from 0 to 17, the first non-zero number ever
# rendered on that screen. Nothing froze and no other screen changed. The global
# bucket rows ride along and are harmless; they are what the MY CLUB summary and
# hub tile WOULD read if anything ever selected those cases (see REBUILD_RESEARCH
# S19, still open). FUT_CLUBSTATS=0 reverts to the old {}.
CLUBSTATS = os.environ.get("FUT_CLUBSTATS", "1") == "1"


def _counts_for(players):
    """The per-collection counts, from a list of player items."""
    # Gold/silver/bronze is FIFA's rating convention (75+/65-74/under), NOT read out of
    # the binary. If a tile ever disagrees, this is the line to doubt.
    return [
        ("players",       len(players)),
        ("playersGold",   len([i for i in players if (i.get("rating") or 0) >= 75])),
        ("playersSilver", len([i for i in players if 65 <= (i.get("rating") or 0) < 75])),
        ("playersBronze", len([i for i in players if 0 < (i.get("rating") or 0) < 65])),
        ("rarePlayers",   len([i for i in players if i.get("rareflag")])),
    ]


def _club_stat_context(kind):
    """Per-context rows, keyed the way the READER actually looks them up.

    Read out of the club-stats provider FUN_180043b90, not guessed. The per-context
    getter is `(+0x7f8)(store, contextValue, typeId)`, and the crucial detail is where
    contextValue comes from: THE UI ROW, not the URL.

        case 3:  uVar7 = (**(param_2 + 0x18))(param_2, row, "LEAGUE_ID")
                 bronze = (+0x7f8)(store, uVar7, 2)
                 silver = (+0x7f8)(store, uVar7, 3)
                 gold   = (+0x7f8)(store, uVar7, 4)
                 publish "PLAYERS_EMPLOYED", gold + silver + bronze
                 rare   = (+0x7f8)(store, uVar7, 5)
                 kits   = (+0x7f8)(store, uVar7, 0x28)
                 badges = (+0x7f8)(store, uVar7, 0x2d)
        case 4:  keyed by "TEAM_ID"; reads 1 (players), 0x28 (kits), 0x2e (badgeDBid)

    THREE CONSEQUENCES, all of which my first attempt got wrong:

    1. One response must carry a bucket for EVERY ROW the screen will show, because
       the reader iterates rows and looks up each row's own id. Keying everything to
       the id in the URL, which is what I did first, fills exactly one bucket that the
       screen never asks for.
    2. `PLAYERS_EMPLOYED` is COMPUTED as gold + silver + bronze. It is never read from
       the store in the per-context cases, so sending `players` (type id 1) does
       nothing there. The tier counts are mandatory, not decoration.
    3. The screens NEST: country/<id> lists the LEAGUES in that nation (case 3, keyed
       by LEAGUE_ID) and league/<id> lists the TEAMS (case 4, keyed by TEAM_ID). That
       matches the live navigation exactly: selecting ENGLAND produced a Premier
       League / Championship / League One / League Two list.

    Because every response WIPES the whole map, each response only needs the buckets
    for its own screen. That is also what keeps nation ids and league ids from
    colliding: the storage key is contextValue alone, so nation 14 and league 14 would
    otherwise share a bucket. One kind per response, no collision.

    contextId 3 is used because it is OUTSIDE the guard (contextId == 1, or 5..9,
    force contextValue to 0) and therefore preserves contextValue. `TODO/CONFIRM`
    whether contextId carries further meaning; nothing read so far gives it one.
    """
    players = [i for i in STORE.items() if _is_player(i)]
    # kind -> which id the SCREEN's rows are keyed by.
    #   ""        the MY CLUB tab strip itself: its nation tiles and the eight-row
    #             panel FUN_180094ce0, which computes PLAYERS_EMPLOYED as
    #             +0x7f8(nationId, 4) + (nationId, 3) + (nationId, 2). Per NATION.
    #   country   the leagues inside a nation (case 3, keyed by LEAGUE_ID)
    #   league    the teams inside a league (case 4, keyed by TEAM_ID)
    field = {"": "nation", "country": "leagueId", "league": "teamid"}.get(kind)
    if not field:
        return [], 0
    ctxs = sorted({i.get(field) for i in players if i.get(field) is not None})
    rows = []
    for ctx in ctxs:
        sel = [i for i in players if i.get(field) == ctx]
        if field == "teamid":
            counts = [("players", len(sel)), ("kits", 0), ("badgeDBid", 0)]
        else:
            counts = [c for c in _counts_for(sel) if c[0] != "players"]
            counts += [("kits", 0), ("badges", 0)]
        rows += [{"contextId": 3, "contextValue": int(ctx), "type": t, "typeValue": int(v)}
                 for t, v in counts]
    return rows, len(ctxs)



# FUT_CONSUM_STATS: answer the CONSUMABLES panel with CONSUMABLE counts.
#
# LIVE 2026-08-05, and this is the whole reason the tab was empty. The client asks
# GET club/stats/consumables 41 times a session and we answered it with the PLAYER
# stat set (players 205, playersGold 189 ...). The panel reads a different set of
# names entirely, so it was told about footballers when it asked about contracts.
# The player screenshot is unambiguous: seven categories -- Training, Contracts,
# Fitness, Healing, Chemistry Style, Manager League, Position Modifier -- every one
# reading 0, on a tab that is present and selectable.
#
# The vocabulary is not a string table: FUN_18012fd40 looks the `type` string up in
# the ATOM table and switches on 40 atom ids, default `return 0`. An unrecognised
# name is therefore INERT, not fatal, which is what makes appending these rows safe.
#
# COUNT THE SHELF, NOT THE STORE. The consumables we serve are a synthetic overlay
# and are never granted into the save, so STORE.items() holds none of them and
# counting it yields fourteen zeros -- which looks exactly like failure on screen and
# would make the experiment unreadable.
#
# Default ON: answering the consumables panel with player counts is wrong by
# inspection, not a judgement call. Set FUT_CONSUM_STATS=0 to go back.
CONSUM_STATS = os.environ.get("FUT_CONSUM_STATS", "1") == "1"
# Serve consumables as TRADEABLE by default; see _consumable_stacks for why.
CONSUM_UNTRADEABLE = os.environ.get("FUT_CONSUM_UNTRADEABLE", "0") == "1"

# FUT_CLUBITEMS: balls, stadia, badges, kits and league logos.
#
# Counts FIRST, deliberately. The consumables round proved that the client does not
# request an item list until club/stats reports a non-zero count for that family, and
# the CLUB tab reads exactly these ids: 0x1e balls, 0x28 kits, 0x14 stadia. So arming
# the counters is what makes the client name the item route it uses -- which is the
# one thing no amount of static reading has produced for this family, because
# cardtype 9 has no arm in the merge at all.
#
# Default OFF: nothing here has ever been requested by the client, so unlike the
# consumables stat fix this is not correcting a demonstrably wrong answer.
# "1" serves each family on its own type= arm and serves NOTHING for equippables.
# "probe:<family>" serves one item per candidate cardsubtypeid for that family only.
_CLUBITEMS_MODE = os.environ.get("FUT_CLUBITEMS", "")
CLUBITEMS = bool(_CLUBITEMS_MODE) and _CLUBITEMS_MODE != "0"


def _consumable_stat_rows():
    """[(stat name, count)] for the consumables panel, counted from the shelf."""
    if not (CONSUM_STATS and CONSUMABLES):
        return []
    try:
        import fut_consumables
        import fut_club_stats
    except Exception as e:                      # never break the panel over this
        log("    CLUBSTATS: consumable rows unavailable (%s)" % e)
        return []
    shelf = fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
    g = fut_club_stats.global_counts(shelf)
    rows = [(fut_club_stats.VOCAB[sid], v) for sid, v in sorted(g.items())
            if sid >= 0x3C and sid in fut_club_stats.VOCAB]
    log("    CLUBSTATS: %d consumable row(s) from a shelf of %d" % (len(rows), len(shelf)))
    return rows


def _club_stat_set():
    """The complete global stat set, computed from what the club actually holds."""
    items = STORE.items()
    players = [i for i in items if _is_player(i)]
    rare = [i for i in players if i.get("rareflag")]
    # Gold/silver/bronze is FIFA's rating convention (75+/65-74/below), NOT something
    # read out of the binary. Marked as a convention because it is one; if a tile ever
    # disagrees, this is the line to doubt.
    gold = [i for i in players if (i.get("rating") or 0) >= 75]
    silver = [i for i in players if 65 <= (i.get("rating") or 0) < 75]
    bronze = [i for i in players if 0 < (i.get("rating") or 0) < 65]
    # Staff the club is CURRENTLY BEING SERVED (the FUT_COACHES / FUT_MANAGERS
    # overlay). All zero unless a flag is set. This is the free second oracle for the
    # staff round: the eight-row panel's STAFF_EMPLOYED number moves without the merge
    # being involved at all, so "our club really holds N staff" stays separable from
    # "the client resolved the card". Keyed by cardsubtypeid: 4 manager, 5 head coach,
    # 6 GK coach, 7 physio, 8 fitness coach.
    _staff = _staff_overlay_counts()
    counts = [
        ("players",       len(players)),
        ("playersGold",   len(gold)),
        ("playersSilver", len(silver)),
        ("playersBronze", len(bronze)),
        ("rarePlayers",   len(rare)),
        # Honest zeros: this club holds no non-player items of any kind... unless a
        # staff overlay is armed, in which case _staff below is what it holds.
        ("staff",         sum(_staff.values())),
        ("stadia",        0),   # 0x14, read directly by STADIA_OWNED
        ("balls",         0),   # 0x1e, read directly by BALLS_EARNED
        ("kits",          0),
        ("badges",        0),
        ("trophies",      0),
        # The eight-row panel FUN_180094ce0 does NOT read staff(0xa) or trophies(0x32).
        # It SUMS the sub-types: STAFF_EMPLOYED = +0x800 over 0xb..0xf, and
        # TROPHIES_WON = +0x800 over 0x33..0x38. Sending the parent ids alone can
        # never move those two rows. All zero today because the club owns no staff and
        # has won nothing, but the mapping is what matters when it does.
        ("staffManager",            _staff[4]),   # 0xb
        ("staffHeadCoach",          _staff[5]),   # 0xc
        ("staffGKCoach",            _staff[6]),   # 0xd
        ("staffPhysio",             _staff[7]),   # 0xe
        ("staffFitnessCoach",       _staff[8]),   # 0xf
        ("trophiesOffline",         0),   # 0x33
        ("trophiesOnline",          0),   # 0x34
        ("trophiesFeaturedOffline", 0),   # 0x35
        ("trophiesFeaturedOnline",  0),   # 0x36
        ("trophiesSeasonOffline",   0),   # 0x37
    ]
    counts += _consumable_stat_rows()
    if CLUBITEMS:
        import fut_clubitems
        ci = fut_clubitems.counts()
        # REPLACE the honest zeros above rather than appending a second row per name:
        # the deserializer writes store[contextValue][statId] = typeValue, so a later
        # row silently wins and two rows for one id is a coin toss.
        have = dict(ci)
        counts = [(t, have.get(t, v)) for t, v in counts]
        for t, v in ci:
            if t not in [c[0] for c in counts]:
                counts.append((t, v))
        log("    CLUBITEMS: %s" % ", ".join("%s=%d" % kv for kv in ci))
    # All four keys in every element -- see note 3 above.
    return [{"contextId": 1, "contextValue": 0, "type": t, "typeValue": int(v)}
            for t, v in counts]


# GET ut/%s/club/consumables/<category> -- the consumables ITEM list.
#
# FOUND LIVE 2026-08-05, and only because the counts were fixed first. The client
# does not ask for consumables through club?type=; it asks here, and it asks ONLY
# once club/stats/consumables reports a non-zero count. So the counter was the gate
# and this route is the door. Before this line existed the path fell through to the
# generic /club prefix and the consumables screen was answered with the 194-card
# player list.
#
# The segment names come from the UI group table at 0x180203260 (codes 0x00 training,
# 0x01 contracts, 0x04 fitness, 0x03 healing, 0x17 playStyle, 0x18
# managerLeagueModifier, 0x11 position). "training" and "contracts" are CONFIRMED on
# the wire; the other five are from that table and are matched case-insensitively,
# with the singular "contract" accepted because the client has used both spellings.
#
# The category numbers are fut_consumables' own, and they line up with the panel
# exactly: 0 -> Training 42, 2+3 -> Contracts 13, 4 -> Healing 21, 5 -> Fitness 6,
# 8 -> Position Modifier 20, 9 -> Chemistry Style 24. That correspondence is what
# makes an empty list here distinguishable from a wrong mapping.
CLUB_CONSUMABLE_CATS = {
    "training":              {0},
    "contracts":             {2, 3},
    "contract":              {2, 3},
    "fitness":               {5},
    "healing":               {4},
    "position":              {8},
    "playstyle":             {9},
    "managerleaguemodifier": {10},
}


_DATA_DIR_TABLES = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                                "..", "data", "tables")
_CARDASSET_BY_RESOURCE = None


def _cardasset_map():
    """carddbid -> cardassetid, from the game's own fcc_* tables.

    THE GREEN "NOT FOUND" BOX. A consumable card draws its art from cardassetid, and
    that is a SMALL art id in the fcc tables (3 training, 7 contract, 10 healing,
    45 misc), NOT the carddbid. fut_store._item copies the resourceId into
    cardassetid, which is right for players and wrong here: the client looked for art
    id 5003001, found none, and fell back to
    external/ion_fut/artAssets/.../notfound.swf -- the green placeholder a player
    photographed on 2026-08-05.

    Built from data/tables/fcc_*.json, which were dumped read-only from the client's
    own database, so these are the game's ids and not a guess.
    """
    global _CARDASSET_BY_RESOURCE
    if _CARDASSET_BY_RESOURCE is None:
        import glob
        m = {}
        for f in glob.glob(os.path.join(_DATA_DIR_TABLES, "fcc_*.json")):
            try:
                rows = json.load(open(f)).get("rows") or []
            except Exception:
                continue
            for r in rows:
                if "carddbid" in r and "cardassetid" in r:
                    m[r["carddbid"]] = r["cardassetid"]
        _CARDASSET_BY_RESOURCE = m
        log("    CONSUMABLES: %d carddbid->cardassetid art mappings loaded" % len(m))
    return _CARDASSET_BY_RESOURCE


def _consumable_stacks(items):
    """Wrap consumables as the STACK records this response class actually reads.

    RESOLVED LIVE 2026-08-05 the hard way: we served bare items here, the client
    took the response and inserted NOTHING (the card map held only the 11 squad
    players), and the screen stayed empty with no error anywhere.

    FutConsumablesSearchServerResponse (RS4 literal 0x1802222f8, factory
    0x180130a10, vtable 0x180222200, deserializer +0x08 = 0x180130d10, 6873 chars)
    takes itemData(0x16b) at the root like the club list, but its ELEMENT is not an
    item. It is a five-atom wrapper and only ONE of those five carries the item:

        0xbc  count             int
        0xd7  discardValue      int
        0x16a item              -> FUN_18013fe00, the item parser itself
        0x287 resourceId        int
        0x362 untradeableCount  int

    Everything else falls to the value-skip handler, which is why a bare item was
    accepted and silently did nothing. That is also why FUT draws consumables as one
    stack with a quantity rather than N separate cards.

    Identical consumables are therefore COLLAPSED by resourceId and counted.
    """
    stacks = {}
    for it in items:
        rid = it.get("resourceId")
        s = stacks.get(rid)
        if s is None:
            # untradeable is forced OFF on the copy we serve. The deserializer sets a
            # flag from (untradeableCount < count), so untradeableCount == count means
            # "every copy is untradeable" and the card draws the untradeable badge --
            # the green tag a player asked about on 2026-08-05. In FIFA 17 a
            # pack-opened consumable is normally tradeable, so the badge was our own
            # data showing through, not the client being wrong. FUT_CONSUM_UNTRADEABLE=1
            # restores the old behaviour.
            item = dict(it)
            if not CONSUM_UNTRADEABLE:
                item["untradeable"] = False
            art = _cardasset_map().get(rid)
            if art is not None:
                item["cardassetid"] = art
            s = stacks[rid] = {"count": 0, "discardValue": 0, "item": item,
                               "resourceId": rid, "untradeableCount": 0}
        s["count"] += 1
        if CONSUM_UNTRADEABLE and it.get("untradeable"):
            s["untradeableCount"] += 1
    return list(stacks.values())


def club_consumables_route(h):
    seg = h.path.split("/club/consumables", 1)[-1].split("?")[0].strip("/").lower()
    if not CONSUMABLES:
        # Flag off: the club genuinely holds none. An empty itemData is the honest
        # answer and is the same shape the client already accepts elsewhere.
        return 200, {"itemData": []}
    import fut_consumables
    shelf = fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
    cats = CLUB_CONSUMABLE_CATS.get(seg)
    if cats is None:
        # An unknown segment: serve the whole shelf rather than nothing, so a
        # spelling we have not seen still shows cards instead of an empty screen,
        # and log it loudly because it is a new wire fact worth acting on.
        log("    CONSUMABLES: UNKNOWN category %r -- serving the whole shelf. Add it "
            "to CLUB_CONSUMABLE_CATS." % seg)
        items = shelf
    else:
        items = [i for i in shelf
                 if fut_consumables.BY_SUBTYPE[i["cardsubtypeid"]]["category"] in cats]
        log("    CONSUMABLES: %s -> %d item(s)" % (seg or "(none)", len(items)))
    stacks = _consumable_stacks(items)
    log("    CONSUMABLES: %d item(s) collapsed into %d stack(s)" % (len(items), len(stacks)))
    return 200, {"itemData": stacks}


def club_stats_route(h):
    mode = h.path.split("/club/stats/", 1)[-1].split("?")[0] if "/club/stats/" in h.path else ""
    if not CLUBSTATS:
        return 200, {}
    if mode.startswith("staff"):
        # FutStaffBonus, a different class with a different shape. {} is safe and
        # deliberately does not disturb the Stats2 map.
        return 200, {}
    stats = _club_stat_set()
    # Per-context modes carry an id in the URL: country/<nation>, league/<id>,
    # team/<id>. Those tabs read a bucket keyed by that id, so the global rows above
    # are invisible to them. Both sets go in the SAME response because every response
    # wipes the whole map first, so anything left out of this body is erased.
    parts = mode.split("/")
    if len(parts) >= 2 and parts[1].isdigit():
        # The id in the URL says which screen we are on, NOT which bucket to fill.
        # country/<n> renders a list of leagues keyed by LEAGUE_ID; league/<n> renders
        # a list of teams keyed by TEAM_ID. So fill every bucket that screen can show.
        ctx_rows, n = _club_stat_context(parts[0])
        stats = stats + ctx_rows
        log("    CLUBSTATS: %s -> %d rows (global players=%d, %d %s buckets)"
            % (mode, len(stats), stats[0]["typeValue"], n,
               {"country": "league", "league": "team"}.get(parts[0], parts[0])))
        return 200, {"stat": stats}
    # No id in the URL: this is the MY CLUB tab strip (year / consumables / club /
    # newcards). Its nation tiles and its eight-row panel read PER-NATION buckets.
    # LIVE 2026-08-04: the ENGLAND tile read 0 while its own Premier League row read
    # 17, which is this bug exactly one level up -- the leagues were keyed and the
    # nations were not.
    ctx_rows, n = _club_stat_context("")
    stats = stats + ctx_rows
    log("    CLUBSTATS: %s -> %d rows (global players=%d, %d nation buckets)"
        % (mode or "(none)", len(stats), stats[0]["typeValue"], n))
    return 200, {"stat": stats}


# FUT_CLUB_PAGE -- an EXPERIMENT, not a fix, aimed at the MY CLUB hub counter.
#
# The counter's renderer is NOT in cardsdll.dll. There is no two-number formatter of
# any spelling in the binary, no tab-strip layout and no display label; the tab entry
# itself carries NAME/UUID/CARD_ID/CARD_TYPE/TAB_INDEX/LEVEL/IS_CLUB and panel-wide
# numTabs/numCards, and nothing else. The composition happens in FIFA17.exe or the
# Scaleform assets, neither of which is readable. Separately, FutStickerBookSearch
# (the GET /club?... parser) has no count or total atom at all: it reads itemData and
# nothing else. So there is no field we can send that IS the number.
#
# What remains testable: if the counter is a Flash-side count over whatever the club
# list returned, then changing the LENGTH of that array moves it. That is a pure
# volume change with no new key and no type change, which makes it the cheapest
# possible discriminator.
#
# The client asks with count=11 (and count=34 when filtering by position). We already
# ignore the query string entirely and return everything, so this flag deliberately
# does the opposite of paging: it is here to make the returned length UNAMBIGUOUS in
# the log so the on-screen number can be compared against it.
#
# A NULL RESULT IS THE VALUABLE ONE. If the counter does not move, the "counter is a
# count of what we sent" hypothesis is dead, and with the binary evidence above that
# leaves no server-side lever at all for this symptom -- which would mean the correct
# outcome is to prove it and stop, not to keep generating candidate bodies.
#
# Risk: paging behaviour on the client side is unknown, so an unexpected length could
# make the club screen misbehave, and then the counter reading is worthless rather
# than informative. Default OFF.
CLUB_PAGE = os.environ.get("FUT_CLUB_PAGE") == "1"


# ---- FUT_ID_SWEEP: use the game itself as the player-database oracle --------
#
# Card identity is never taken from the wire. Every item we serve is registered
# into the client's CardsDb map, and just before that the client merges in its OWN
# local `players` table keyed on `resourceId & 0xffffff`. So serving a RANGE of
# candidate playerids and then reading the map back (tools/card_identity_probe.py)
# classifies the whole range in one pass. That is the entire database-extraction
# problem solved from the outside, without unpacking anything: dbdata.dll turned
# out to be an anti-tamper decoy, and the real table only exists inside the running
# game.
#
# Three outcomes per id, all distinguishable in the record (proven live 2026-08-04):
#   HIT           real name at +0xb8/+0xc8; teamid/nation/leagueId filled from the
#                 DB because we send them as ZERO (the merge only fills zeros).
#   NAMELESS ROW  our sentinel rating survives but the name is the DB's default
#                 row, "Jamal Blackman". The row exists and is empty.
#   TRUE MISS     the merge's miss fill: rating 0x32, teamid 0x78d, nation 0xe.
# SENTINEL_RATING is deliberately NOT 50, so a miss can never be mistaken for it.
#
# The sweep is SYNTHETIC: nothing is written to the save, so clearing the flag
# restores the real club exactly. Format: FUT_ID_SWEEP="<lo>-<hi>".
_ID_SWEEP = os.environ.get("FUT_ID_SWEEP", "")
SWEEP_FILE = os.environ.get("FUT_ID_SWEEP_FILE", "/tmp/fut_id_sweep")
SWEEP_ID_BASE = 900000000        # item ids, distinct from the save's 100000000+
SWEEP_SENTINEL_RATING = 7


def sweep_window():
    """The active sweep window: the control FILE if it has content, else the env.

    Read per REQUEST, on purpose. A full database sweep is many windows, and
    restarting this server to re-aim it is exactly the action that once produced
    "error connecting to FIFA 17 Ultimate Team" mid-session -- a plain connection
    refusal, misread for weeks as a protocol bug. Writing a range into the file
    re-aims the sweep with the client still live; emptying the file restores the
    real club on the very next fetch.
    """
    try:
        with open(SWEEP_FILE) as f:
            s = f.read().strip()
        if s:
            return s
    except (IOError, OSError):
        pass
    return _ID_SWEEP


# Auto-advance state. The client PAGES the club -- one visit produced seven
# GET /club?...start=50&count=11 fetches -- so an auto window can hand out a new
# chunk on every fetch and cover ~7 chunks per visit instead of one. Item ids are
# derived from the candidate's OFFSET IN THE WHOLE RANGE, not from its index in
# the chunk, so chunks never collide in the map and results ACCUMULATE across
# fetches; one probe at the end reads them all.
_SWEEP_SPEC = None
_SWEEP_POS = 0
_SWEEP_SUBTYPE = 0        # 0..3 = player; see _parse_window for the other tables
# The five non-player card tables, as dispatched by FUN_1800d8330:
#   4 -> managercards   5 -> headcoachcards   6 -> gkcoachcards
#   7 -> physiocards    8 -> fitnesscoachcards
_STAFF_SUBTYPES = [4, 5, 6, 7, 8]


def _parse_window(win):
    """'lo-hi' or 'auto:lo-hi:step' -> (lo, hi, step or None). None on garbage.

    An optional 't<subtype>@' prefix sweeps a NON-PLAYER card table. The merge
    FUN_180141660 dispatches on record+0x4c, which FUN_1800d8330 derives from
    cardsubtypeid alone, and each branch queries a different table by
    carddbid = record+0x18 (the same field players use for playerid):

        0..3 -> 1  players            5 -> 3  headcoachcards
        4    -> 2  manager            8 -> 4  fitnesscoachcards
        6    -> 10 gkcoachcards       7 -> 5  physiocards
        9..b -> 7  (unidentified)     absent -> 0x156 -> 0, NO merge at all

    So 't5@auto:1-20000:5000' sweeps head coaches exactly the way the default
    sweeps players. Nothing about the non-player branches is live-proven yet;
    they are read out of the binary.
    """
    global _SWEEP_SUBTYPE
    _SWEEP_SUBTYPE = 0
    if win.startswith("t") and "@" in win:
        head, win = win.split("@", 1)
        if head == "t*":
            _SWEEP_SUBTYPE = -1        # fan every staff table across the range
        else:
            try:
                _SWEEP_SUBTYPE = int(head[1:], 0)
            except ValueError:
                return None
    auto = win.startswith("auto:")
    step = None
    if auto:
        parts = win[5:].split(":")
        rng = parts[0]
        if len(parts) > 1:
            step = int(parts[1], 0)
    else:
        rng = win
    try:
        lo, hi = (int(x, 0) for x in rng.split("-", 1))
    except Exception:
        return None
    if hi < lo:
        lo, hi = hi, lo
    if auto and not step:
        step = 5000
    return lo, hi, step


def sweep_items():
    """Synthetic club contents for the current sweep window.

    5000 candidates per response is live-proven. 20000 was served fine and then
    silently NOT ingested -- the map did not change at all -- so there is a
    ceiling between the two. Auto chunks therefore default to 5000, and a chunk
    that is not ingested costs one fetch, not the sweep.
    """
    global _SWEEP_SPEC, _SWEEP_POS
    win = sweep_window()
    parsed = _parse_window(win)
    if not parsed:
        log("    SWEEP: bad window %r, want '<lo>-<hi>' or 'auto:<lo>-<hi>:<step>'" % win)
        return []
    lo, hi, step = parsed

    if win != _SWEEP_SPEC:                 # re-aimed: restart the walk
        _SWEEP_SPEC, _SWEEP_POS = win, 0

    if step:
        start = lo + _SWEEP_POS
        if start > hi:
            log("    SWEEP: range %d..%d EXHAUSTED -- probe now, then re-aim" % (lo, hi))
            return []
        end = min(start + step - 1, hi)
        _SWEEP_POS += step
    else:
        start, end = lo, hi

    # 't*@' fans EVERY staff subtype across the range at once, so one staff-tab
    # load tests managercards / headcoachcards / fitnesscoachcards / physiocards /
    # gkcoachcards together instead of costing five separate visits.
    subs = _STAFF_SUBTYPES if _SWEEP_SUBTYPE == -1 else [_SWEEP_SUBTYPE]

    # Only cardsubtypeid ever changes. itemType stays "player" because the merge
    # dispatches on the subtype alone and the wire shape of a real staff item has
    # never been observed -- inventing one is the change class that freezes this
    # client.
    out = []
    for pid in range(start, end + 1):
        for si, sub in enumerate(subs):
            it = _item(SWEEP_ID_BASE + (pid - lo) * len(subs) + si, pid,
                       SWEEP_SENTINEL_RATING, "ST", 0, 0, 0, [1, 1, 1, 1, 1, 1])
            if sub:
                it["cardsubtypeid"] = sub
            out.append(it)
    log("    SWEEP: serving %d item(s) for id(s) %d..%d%s subtype(s)=%s "
        "[synthetic, nothing saved]"
        % (len(out), start, end,
           (" (auto, %d..%d done)" % (lo, end)) if step else "",
           ",".join(str(s) for s in subs)))
    return out


# ---- FUT_CONSUMABLES / FUT_COACHES / FUT_MANAGERS: the non-player families -----
#
# Three whole card families are now derivable offline (docs/plan-2026-08-04-card-
# families.md and the 2026-08-05 round): consumables need no id space at all, and
# staff ids came out of the 149 tables dumped read-only from the running client into
# data/tables/. Each ships behind its own flag, DEFAULT OFF.
#
# THEY ARE SERVED AS AN OVERLAY, NOT GRANTED INTO THE SAVE. That is deliberate:
#   * clearing the flag restores the real club exactly, on the very next fetch, with
#     no un-granting and no edit to a save that a live client is holding open;
#   * Store.add_items() uses `setdefault("id", ...)`, so items that arrive with an id
#     already set do NOT advance nextItemId -- granting these would eventually collide
#     two id spaces. Overlay ids come from 9.4e8/9.5e8, clear of the save's 1e8, the
#     sweep's 9e8 and each other.
# The cost is that overlay cards cannot be quick-sold or moved (they are not in the
# save), and the MY CLUB / clubPlayers counters do not see them. The staff COUNTERS
# are set from the overlay below, which is deliberate and is the second, independent
# oracle: "our club reports N staff" is a different signal from "the client resolved
# the card".
#
# EACH FLAG IS TWO-VALUED because the tab-to-?type= binding is UNOBSERVED. Only
# type=player, type=manager and type=custom have ever come from this client, so which
# arm the consumables and staff screens ask for is a guess:
#   FUT_CONSUMABLES=1     serve on type=contract|training|healing|development
#   FUT_CONSUMABLES=all   ... and on an untyped club fetch with no team=/league=
#   FUT_COACHES=1         serve on type=headcoach|gkcoach|physio|fitnesscoach|staff
#   FUT_COACHES=all       ... and on type=manager, the ONE staff request ever observed
#   FUT_MANAGERS=1        serve on type=manager|staff
# Every club fetch logs the ?type= it was asked for while any of the three is set, so
# a null result tells the human WHICH arm to aim at instead of nothing at all.
def _family_flag(name):
    """"" / "0" / "off" all mean OFF. Without this, FUT_CONSUMABLES=0 would be a
    non-empty string and would quietly turn the family ON -- the opposite of what
    anyone typing it means."""
    v = os.environ.get(name, "").strip().lower()
    return "" if v in ("", "0", "off", "no", "false") else v


CONSUMABLES = _family_flag("FUT_CONSUMABLES")
COACHES = _family_flag("FUT_COACHES")
MANAGERS = _family_flag("FUT_MANAGERS")
FAMILIES_ON = bool(CONSUMABLES or COACHES or MANAGERS)

MANAGER_TYPES = ("manager", "staff")


def _family_overlay(kind, has_drilldown):
    """The non-player items to serve for this ?type=, or []. Never touches the save."""
    out = []
    if CONSUMABLES:
        import fut_consumables
        if kind in fut_consumables.TYPE_CATEGORIES:
            out += fut_consumables.items_for_type(kind)
        elif CONSUMABLES == "all" and not kind and not has_drilldown:
            out += fut_consumables.starter_consumables(fut_consumables.CONSUMABLE_ID_BASE)
    if COACHES:
        import fut_coaches
        if kind in fut_coaches.CLUB_TYPES:
            out += fut_coaches.items_for_type(kind)
        elif COACHES == "all" and kind == "manager":
            # type=manager is the ONE staff request ever observed on the wire (STAFF
            # tab, 2026-08-04). If the tab only ever asks under that name, this is the
            # only arm that can put a coach on screen.
            out += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE)
    if MANAGERS:
        import fut_staff
        if kind in MANAGER_TYPES:
            out += [fut_staff.manager_item(fut_staff.OVERLAY_ID_BASE + i, c)
                    for i, c in enumerate(fut_staff.STARTER_MANAGERS)]
    return out


def _staff_overlay_counts():
    """(staffManager, headCoach, gkCoach, physio, fitnessCoach) held by the overlay.

    FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over stat ids 0xb..0xf, so the
    parent id 0xa can never move it -- the five sub-types are what count. Zero unless a
    staff flag is set, so the default panel is unchanged."""
    n = {4: 0, 5: 0, 6: 0, 7: 0, 8: 0}
    if MANAGERS:
        import fut_staff
        n[4] = len(fut_staff.STARTER_MANAGERS)
    if COACHES:
        import fut_coaches
        for it in fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE):
            n[it["cardsubtypeid"]] = n.get(it["cardsubtypeid"], 0) + 1
    return n


def club_route(h):
    # PUT only -- ENDPOINT_MAP row 3 gives ChangeClubName as PUT. Every other
    # method keeps the exact body this route served before, so the rename support
    # cannot change the behaviour of anything that already worked.
    if h.command == "PUT":
        return club_rename_route(h)
    if sweep_window():
        return 200, {"itemData": sweep_items()}
    items = STORE.items()

    # HONOUR ?type=. This route ignored it, so the STAFF tab -- which asks for
    # type=manager, observed live 2026-08-04 -- was answered with the player list
    # and displayed footballers as coaching staff.
    #
    # Filtering is deliberately NARROW. `player` and `manager` are the two values
    # actually observed; `custom` (the by-league and by-team drill-downs) and a
    # missing type keep exactly the behaviour that is already live-proven on
    # screen, because the drill-down counts were only just fixed and this must not
    # disturb them. An unrecognised type is treated like manager -- filtered, not
    # unfiltered -- since answering an unknown question with the whole player list
    # is what produced this bug in the first place.
    q = {}
    if "?" in h.path:
        for part in h.path.split("?", 1)[1].split("&"):
            if "=" in part:
                k, v = part.split("=", 1)
                q[k] = v
    kind = q.get("type", "")

    # HONOUR ?team= AND ?league=. These are the CLUB DRILL-DOWNS: clicking Chelsea in
    # the club panel issues team=5, clicking the Premier League issues league=13.
    # They were ignored, so every drill-down was answered with the ENTIRE club and
    # Cristiano Ronaldo showed up under Chelsea, Arsenal and everyone else. Reported
    # live 2026-08-05. The counts on the stats panel were right all along; it was
    # only the item list that was unfiltered.
    has_drilldown = False
    for param, field in (("team", "teamid"), ("league", "leagueId")):
        raw = q.get(param)
        if raw is None:
            continue
        try:
            want = int(raw)
        except ValueError:
            continue
        has_drilldown = True
        items = [i for i in items if i.get(field) == want]
        log("    CLUB: %s=%d -> %d item(s)" % (param, want, len(items)))

    # CLUB ITEMS. Observed live 2026-08-05, and the names are SINGULAR: the client
    # asks type=stadium, type=ball and type=equippables (count=11 for the last,
    # count=200 for the other two). Those were served from STORE.items(), which holds
    # no club items because the shelf is synthetic, so they correctly came back empty.
    # equippables is the combined view behind the club-customisation screen.
    if CLUBITEMS and kind in ("stadium", "ball", "badge", "kit", "leaguelogos",
                              "equippables"):
        import fut_clubitems
        fam = {"stadium": "stadia", "ball": "balls", "badge": "badges",
               "kit": "kits", "leaguelogos": "leaguelogos"}.get(kind)
        if _CLUBITEMS_MODE.startswith("probe:"):
            want = _CLUBITEMS_MODE.split(":", 1)[1]
            got = fut_clubitems.probe_shelf(want) if fam == want else []
            log("    CLUBITEMS: PROBE %s on type=%s -> %d item(s), one per candidate "
                "subtype" % (want, kind, len(got)))
            return 200, {"itemData": got}
        if fam is None:
            # equippables is the COMBINED view and it is what crashed the client on
            # 2026-08-05: 30 items across five unverified subtypes in one response.
            # Until the subtypes are confirmed one family at a time, answer it empty.
            # An empty itemData is a shape the client already accepts everywhere.
            log("    CLUBITEMS: type=equippables -> [] (combined view withheld until "
                "the subtypes are verified; it crashed the client once)")
            return 200, {"itemData": []}
        got = fut_clubitems.shelf(families={fam}).get(fam, [])
        log("    CLUBITEMS: type=%s -> %d item(s)" % (kind, len(got)))
        return 200, {"itemData": got}

    if kind and kind not in ("player", "custom"):
        # cardsubtypeid 0..3 is a player (FUN_1800d8330); everything else is
        # staff or a manager. The save holds no non-player items (the families
        # below are served as an overlay, not granted), so this is [] today --
        # an empty item list, which is the same shape the parser already accepts.
        items = [i for i in items if i.get("cardsubtypeid", 0) not in (0, 1, 2, 3)]
        log("    CLUB: type=%s -> %d item(s) (players filtered out)" % (kind, len(items)))
    else:
        # THE MIRROR FILTER. This branch -- type=player, type=custom, and an untyped
        # fetch -- used to filter NOTHING, so the moment the club held a non-player
        # item it would be served straight into the players tab and into the by-league
        # / by-team drill-downs. A manager carries nation, leagueId and teamid, so he
        # would have appeared as a footballer in exactly the MY CLUB rows that were
        # only just made non-zero. Provable no-op today: all 194 items in the live save
        # are cardsubtypeid 0, so this list is unchanged (verified 2026-08-05).
        items = [i for i in items if i.get("cardsubtypeid", 0) in (0, 1, 2, 3)]
        if CLUB_PAGE:
            log("    CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare "
                "this number against the MY CLUB counter on screen]" % len(items))

    if FAMILIES_ON:
        # The tab-to-?type= binding is unobserved, so LOG EVERY ARM ASKED FOR. If a
        # family tab comes back empty this line is what says whether the request even
        # reached us and under which name -- the difference between "aim at another
        # arm" and "nothing was asked".
        overlay = _family_overlay(kind, has_drilldown)
        log("    CLUB: family overlay armed (consumables=%r coaches=%r managers=%r); "
            "type=%r drilldown=%s -> +%d item(s)"
            % (CONSUMABLES, COACHES, MANAGERS, kind, has_drilldown, len(overlay)))
        items = items + overlay
    return 200, {"itemData": items}


def user_route(h):
    if h.command == "POST":
        return 200, user_post(h)
    if NEW_USER:
        # accepted, non-fatal: 0x18007AF20 treats 404 as the new-user branch
        return 404, {}
    return 200, user_get()


# ut/%s/squad carries the WHOLE squad family (request table 0x18021dfc0): GET =
# LoadActiveSquad AND GetSquadList/GetSquads, PUT = SaveCurrentSquad/SaveSquad.
# Load-vs-List is not resolvable statically (one binding row each, 0x18027cb70 /
# 0x18027be28) -- but the two parsers are mutually SKIP-tolerant: the squad-object
# parser 0x18013d1f0 does not recognise "squad"(0x2cd), and the list parser
# 0x180142260 recognises ONLY "squad". So a MERGED body satisfies both. Kept behind
# FUT_SQUAD_LIST=merged for now (GET /squad is boot-critical; default keeps the
# proven plain-squad body so a live test isolates one change at a time).
_SQUAD_LIST_MODE = os.environ.get("FUT_SQUAD_LIST", "off")


def clientdata_route(h):
    """ut/%s/clientdata/<key> -- opaque client blob storage.

    LIVE-OBSERVED: `PUT ut/game/fifa17/clientdata/userHubData` fires from the FUT
    hub (20:40 session). The client is storing its own hub state -- so the correct
    server behaviour is to keep the blob and hand back exactly what was given, which
    is zero-risk by construction: we never synthesise a shape, we echo the client's
    own bytes. Persisting it is also the most plausible route to the hub's
    "MANAGER TASKS 0/0" tile surviving a relaunch, since no FutGetObjectives class
    exists in the binary at all (§9) -- the tile state may simply live in this blob.
    """
    key = h.path.split("/clientdata/", 1)[-1].split("?")[0] or "default"
    if h.command in ("PUT", "POST"):
        try:
            body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
        except Exception:
            body = None
        if body is not None:
            STORE.set_clientdata(key, body)
            log("    CLIENTDATA: stored %r (%d bytes)" % (key, len(h._body)))
        return 200, {}
    return 200, STORE.get_clientdata(key)


# ---- GAME MODES: seasons / tournaments / leaderboards / champions ------------
# These were 15 of the 45 templates in the URL table with NO route at all -- they
# fell through to the catch-all {}. Schemas below come from ENDPOINT_MAP.md (the
# same source that already had the match loop right); confidence per endpoint is
# noted inline.
#
# DEFAULT OFF. Every body here is documented-but-never-live-tested, and today's two
# regressions were both "serve a new body the client has never parsed". Notably
# FutSeasonList wants an ARRAY root where we currently send {} on a boot-adjacent
# path -- exactly the shape class that freezes at 0x1801c7f1a if wrong. Turn on
# with FUT_MODES=1 when you can watch a launch; `unset FUT_MODES` is the fallback.
_MODES = os.environ.get("FUT_MODES") == "1"


def season_list():
    """GET ut/%s/season -- FutSeasonListServerResponse, deser 0x1801683f0.

    CORRECTED 2026-08-04. This function and its docstring were BOTH wrong, in the
    same way, and the error is instructive:

      * The deserializer is 0x1801683f0, not 0x180167740. 0x180167740 is the
        per-ELEMENT parser.
      * The root is therefore an OBJECT, not an array. 0x1801683f0 runs a key loop
        and matches exactly ONE atom, seasons(0x2ad); only inside that does an array
        open. Someone read the element parser, saw its key set, and served those keys
        at the document root. A bare array populates nothing at all.

    Three of the keys previously served here (eligibilityKey, eligibilitySlot,
    eligibilityValue) are inner members of elgReq and are completely inert at element
    level, so even the element shape was wrong.

    Ordering matters inside an element: `type` MUST precede `divisionId`, because the
    divisionId branch reads the already-parsed type field at elem+0x1b4.

    Still omitted, and now for a stated reason: prizeSet(0x253), elgReq(0xf7) and
    matches(0x1b8) are all `while (tok != 0xd)` ARRAY loops. A scalar in any of them
    is the 0x1801c7f1a spin.

    NOT SERVED BY DEFAULT and there is no point serving it yet: across 486 real
    client requests (User-Agent ProtoHttp, roughly 30 boots) the game has NEVER asked
    for /season. Every /season line in our log is our own curl or urllib. A body here
    changes nothing observable, so this is correctness-in-waiting, not a fix.
    Semantic hazard for whenever it does ship: omitting untilEndSeconds makes the
    season's end timestamp equal to now.
    """
    return {"seasons": [{"type": "OFFLINE", "id": 1, "divisionId": 10}]}


def season_user():
    """GET ut/%s/season/user -- FutSeasonLoadData, deser 0x180131450 (HIGH,
    switch fully traced at 0x18013153c). `data`(201) is an opaque interned blob
    string; empty is valid. friendlySeasonHistory is nested -> omitted."""
    return {"seasonId": 1, "divisionId": 10, "round": 1, "userPoints": 0,
            "dataVersion": "1", "data": ""}


def tournament_list():
    """GET ut/%s/tournament -- FutTournamentList, deser 0x180169ef0 (MEDIUM).
    ARRAY root; rounds/prizeSet/staff/kit atoms are nested FREEZE-RISK -> omitted."""
    return [{"id": 1, "difficulty": 1, "coins": 500, "rewardMultiplier": 1,
             "assetName": "", "eligibilityOperation": ""}]


def tournament_user():
    """GET ut/%s/tournament/user -- FutTournamentLoadData 0x180147cb0 (MEDIUM).
    Mirrors the season shape; tournamentData(810) is the same interned blob."""
    return {"round": 1, "dataVersion": "1", "tournamentData": ""}


def leaderboard_route(h):
    """GET ut/%s/leaderboards -- FutGetLBEntries 0x180144c8d (MEDIUM).
    /options -- FutGetLBOptions 0x18014351c. Empty entries list is the safe body:
    an empty array cannot desync the reader."""
    if "/options" in h.path:
        return 200, {"category": 0, "id": 0, "period": 0, "view": 0, "url": ""}
    return 200, {"entries": []}


# ---- Draft current state -----------------------------------------------------
# GET ut/%s/squad/mode/draft/state?mode=ONLINE|SINGLE_PLAYER
#   -> FutGetDraftCurrentStateServerResponse, deser 0x180147070.
#
# THE ROOT CONTAINER IS A JSON ARRAY, not an object. That single fact is the whole
# finding. The deserializer's prologue discards tokens until it sees START_ARRAY;
# handed a top-level object it never reaches its exit condition and spins in the
# inner `while (tok != END_OBJECT)` loop with the tokenizer returning EOF forever.
# Process alive, no crash dump, no error dialog: exactly the signature observed live
# on 2026-08-03 when our generic /squad route answered this with a full active-squad
# object (23 slots, nested itemData, the 33-int `custom` string).
#
# Verified, not assumed:
#   * 7 top-level atoms, counted at instruction level (4 int-getter calls, 3
#     string-getter calls, 0 bool, 2 skip) over the whole body [0x180147070,
#     0x1801475a3]. FUN_180135ff0 IS present (2 sites), so unknown keys are inert.
#   * NO ATOM COLLIDES with the squad object we were serving. The freeze was purely
#     the container level, not a per-field type desync.
#   * roundsInfo[] element parser FUN_180146eb0 (2477 chars, read in full): 7 scalar
#     atoms, all safe. Empty array is safest and is what we send.
#   * entranceCriteria(0x108) is an object of three int keys COINS/DRAFT_TOKEN/
#     POINTS. OMITTED here: knowing a shape is not a reason to send it.
#   * squadState(0x2d5) is a string enum. "DRAFTSQUAD_ON" from the old notes is NOT
#     an accepted spelling.
#
# A reviewer independently simulated this exact body through the deserializer line by
# line: 16 token reads, clean exit, nothing left over.
#
# DEFAULT ON, which is a deliberate exception to "default to the live-proven value".
# The live-proven value here HANGS THE GAME. There is no working screen to protect:
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
# this turns out to be wrong.
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"


def draft_state_route(h):
    if not DRAFT_STATE:
        return squad_route(h)
    return 200, [{
        "squadState":           "INVALID",   # 0x2d5 STRING enum
        "stateParam1":          "INVALID",   # STRING
        "stateParam2":          "0",         # STRING (the int getter also accepts it)
        "gamesWonCurrentMatch": 0,           # INT
        "roundsInfo":           [],          # array of the 7-scalar element; empty is safe
        # entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
    }]


# ---- Draft entry purchase ----------------------------------------------------
# POST ut/%s/purchase/mode/{price}/draft   body {"currency":"COINS","usePreOrder":0}
#   -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns
#      the fresh draft session summary.
#
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
# client CRASHED immediately after. Sequence, from the log:
#     GET  /squad/mode/draft/state?mode=ONLINE  -> our array body, screen RENDERED
#     GET  /user/credits                        -> 7200
#     GET  /store/purchasegroup/all             -> the entry-fee screen
#     POST /purchase/mode/0/draft               -> {}   then the crash
# So the draft-state fix worked and simply advanced the failure to the next
# unimplemented call, which is the outcome a correct fix is supposed to have.
#
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
# structures reference the class name. Resolved this session, and the doc's note about
# the second one is wrong:
#   0x18014c260  (vtable 0x180224ef8, factory 0x18014c090)  3188 chars, OBJECT root
#                (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly
#                the seven scalar ints below. THIS IS THE RESPONSE PARSER.
#   0x180150310  (vtable 0x1802262f0, factory 0x180150260)  1836 chars, ARRAY root
#                (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a
#                response root at all. It parses ENTRANCE CRITERIA: each element's
#                name is strcmp'd against the literals "COINS", "POINTS" and
#                "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name
#                string because it is the fee sub-object, not an alternate envelope.
#
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An
# object-root parser handed {} parses benignly and leaves defaults; an array-root
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran,
# and the failure is downstream of an empty-but-valid parse. That is consistent with
# 0x18014c260 and inconsistent with 0x180150310.
#
# All seven members are scalar ints and the skip handler is present, so unknown keys
# are inert and there is no freeze surface here.
#
# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the
# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state,
# so there is no fee to charge yet. Charging a guessed amount would be inventing an
# economy rule; when entranceCriteria is served the price becomes real and this is the
# place to take it.
#
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
# confirmed crash, so there is no working state being protected.
DRAFT_PURCHASE = os.environ.get("FUT_DRAFT_PURCHASE", "1") == "1"


def draft_purchase_route(h):
    if not DRAFT_PURCHASE:
        return 200, {}
    # NB: the route table hands handlers the compiled PATTERN, not a match object
    # (the dispatcher calls fn(rx, self)), so re-extract from the path rather than
    # calling .group() on the first argument. Doing that raised AttributeError,
    # which killed the connection outright -- strictly worse than the {} it replaced.
    m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
    price = int(m.group(1)) if m else 0
    log("    DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria "
        "served yet, so the client posted its own price)" % price)
    return 200, {
        "championEventId":     0,
        "expectedTierLevel":   1,
        "gamesPlayed":         0,
        "gamesRemaining":      4,   # a draft run is 4 rounds
        "rank":                0,
        "score":               0,
        "tierLevel":           1,
    }


def champion_route(h):
    """ut/%s/champion -- registration 0x18014980d (ack, no atoms),
    topX 0x18014a09d ({"entries":[]}), friends 0x18014b7ad ({} safe)."""
    if h.command == "POST":
        return 200, {}
    return 200, {"entries": []}


# ---- THE CORE LOOP: match lifecycle + rewards --------------------------------
# Schemas from ENDPOINT_MAP.md (all CONFIDENCE: HIGH, reversed earlier):
#   POST   ut/%s/match          FutCreateMatch  0x180120380
#                               startDateTime(740,int) reportIdEnabled(641,bool)
#                               squad(717,nested -- FREEZE-RISK, omit: SKIP-safe)
#   PUT    ut/%s/match/{id}     FutMatchReady   no deserializer at all -> {}
#   POST   ut/%s/match/{id}     FutPlayGame     no deserializer at all -> {}
#                               (the client SENDS the result here; body ignored)
#   DELETE ut/%s/match/{id}     FutDestroyMatch 0x180121b60  <-- THE REWARDS
#     allCoins(20)@0x28  matchCoins(436)@0x2c  tournamentCoins(809)@0x30
#     teamOfTournamentWinner(776,bool)@0x34  seasonCoins(670)@0x38  coins(149)@0x3c
#     participationAward(529)@0x44  boostConis(96)@0x48  [EA's typo, exact key]
#     qualifiedChampionEventId(617)@0xb0
#     gameModeAward(310) / matchCoinMultipliers(437) / userData(877) are NESTED and
#     SKIP-safe -- omitted deliberately (userData is a documented freeze-risk: it
#     must be an object if present, so the safe move is not to send it).
# Every field we DO send is a top-level scalar -> zero freeze risk.
#
# Reward amounts are ours to choose (the server decides payouts). Defaults are
# FUT-ish and env-tunable; they are NOT reversed values and are not claimed to be.
MATCH_COINS = {
    "won":  int(os.environ.get("FUT_MATCH_COINS_WIN",  "400")),
    "draw": int(os.environ.get("FUT_MATCH_COINS_DRAW", "200")),
    "loss": int(os.environ.get("FUT_MATCH_COINS_LOSS", "100")),
}
MATCH_PARTICIPATION = int(os.environ.get("FUT_MATCH_PARTICIPATION", "0"))

# FUT_MATCH_END -- the 2026-08-04 correction of the whole match tail: route /match/end
# as DestroyMatch regardless of verb, and move `coins` inside gameModeAward where the
# deserializer actually reads it. DEFAULT ON, and like FUT_DRAFT_STATE this is a
# reasoned exception to "default to the live-proven value": nothing here is
# live-proven, because no match has ever been played. The old behaviour is not a
# working screen being protected, it is a path that provably could not fire (it
# required a verb and a /match/{id} URL the client does not use). Set to 0 to revert.
MATCH_END = os.environ.get("FUT_MATCH_END", "1") == "1"


# The DestroyMatch REQUEST, reversed 2026-08-04 from the serializer rather than
# guessed from the response side. This replaces the spelling-probe below as the
# PRIMARY path; the probe stays as a fallback because request-side static findings
# are a floor, not a ceiling (PUT /item's swap/tradeId were in no static listing).
#
#   endReason (atom 260) -- STRING enum, and it is the AUTHORITATIVE result signal.
#     Nine values: WIN DRAW LOSS DNF QUIT NO_CONTEST DNF_WIN DNF_DRAW DNF_LOSS.
#     A score comparison is NOT how the client reports the outcome.
#   myMatchStats / opponentMatchStats -- literal-keyed objects, 15 int fields each,
#     first of which is `goals`. OMITTED BY THE CLIENT when endReason is DNF or QUIT,
#     so nothing may require them.
_END_REASON = {
    "WIN": "won", "DNF_WIN": "won",
    "DRAW": "draw", "DNF_DRAW": "draw", "NO_CONTEST": "draw",
    "LOSS": "loss", "DNF_LOSS": "loss", "DNF": "loss", "QUIT": "loss",
}


def _match_result(body):
    """Work out win/draw/loss from whatever the client posted.

    Primary: endReason, the string enum the serializer actually writes. Fallback:
    the old spelling probe, then a draw, which is the neutral outcome -- it credits
    coins and advances the record without inventing a win. Every body is logged, so
    the first live match still tells us if the static read was incomplete."""
    if not isinstance(body, dict):
        return "draw", None

    reason = body.get("endReason")
    if isinstance(reason, str) and reason.upper() in _END_REASON:
        mine = body.get("myMatchStats") or {}
        theirs = body.get("opponentMatchStats") or {}
        score = None
        if isinstance(mine, dict) and isinstance(theirs, dict):
            a, b = mine.get("goals"), theirs.get("goals")
            if isinstance(a, int) and isinstance(b, int):
                score = (a, b)
        return _END_REASON[reason.upper()], score
    # a nested match/stats object is as likely as a flat one
    for key in ("match", "matchStats", "stats", "result", "gameResult"):
        inner = body.get(key)
        if isinstance(inner, dict):
            r, s = _match_result(inner)
            if s is not None:
                return r, s
    for us, them in (("goals", "opponentGoals"), ("score", "opponentScore"),
                     ("userGoals", "opponentGoals"), ("homeGoals", "awayGoals"),
                     ("ourScore", "theirScore")):
        a, b = body.get(us), body.get(them)
        if isinstance(a, int) and isinstance(b, int):
            return ("won" if a > b else "loss" if a < b else "draw"), (a, b)
    # explicit textual result
    r = body.get("result") or body.get("outcome")
    if isinstance(r, str):
        rl = r.lower()
        for k, v in (("win", "won"), ("won", "won"), ("loss", "loss"),
                     ("lose", "loss"), ("defeat", "loss"), ("draw", "draw"),
                     ("tie", "draw")):
            if k in rl:
                return v, None
    return "draw", None


def destroy_match_body(result, coins, total):
    """FutDestroyMatchServerResponse (deser 0x180121b60) -- PURE, no state.

    Split out of match_route so it can be unit-tested: the match loop mutates
    (credits coins, bumps W/D/L), so it cannot live in the read-only HTTP contract
    suite. See tools/test_match_rewards.py. Every field is a top-level scalar; the
    nested members matchCoinMultipliers(437)/userData(877) are SKIP-safe and
    deliberately omitted (userData is a documented freeze-risk).

    THREE CORRECTIONS from the 2026-08-04 pass over deser 0x180121b60, all of which
    were shipping wrong before:

    1. `coins` (atom 149) is NOT a top-level key of this response. It is read ONLY
       inside the `gameModeAward` object. The top-level "coins" we were sending was
       silently skipped and never reached the client, which means the one field most
       obviously named "the reward" was the one field going nowhere.
    2. `qualifiedChampionEventId` (atom 0x269) HAS A SIDE EFFECT. Its branch does not
       just store the int, it calls through a manager vtable afterwards. Sending it
       as a habitual zero pokes champion-event machinery for no benefit. Removed.
    3. NEVER emit `bidTokens` (atom 89) inside gameModeAward. That atom is explicitly
       matched there and then handled by NOTHING: not read, not routed to the skip
       handler. Its value token is left unconsumed in the stream, which is the exact
       precondition for the type-desync spin. It is a freeze trap wearing the costume
       of an ordinary field.

    FUT_MATCH_END=0 restores the previous body if the new one misbehaves live."""
    body = {
        "allCoins":                 int(total),
        "matchCoins":               int(MATCH_COINS.get(result, 0)),
        "seasonCoins":              0,
        "tournamentCoins":          0,
        "boostConis":               0,              # EA's spelling, atom 96
        "participationAward":       int(MATCH_PARTICIPATION),
        "teamOfTournamentWinner":   False,
    }
    if MATCH_END:
        # `coins` lives here and nowhere else. No bidTokens, ever.
        body["gameModeAward"] = {"coins": int(coins)}
    else:
        body["coins"] = int(coins)
        body["qualifiedChampionEventId"] = 0
    return body


def match_route(h):
    """POST create / PUT ready / POST play / DELETE destroy(+rewards)."""
    try:
        body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
    except Exception:
        body = {}
    m = re.search(r"/match/(\d+)", h.path)
    match_id = int(m.group(1)) if m else None
    # THE REAL URLS, from the RPC descriptor block (rows 49-54, all using template
    # index 16 = `ut/%s/match`, each appending a fixed suffix via the params object
    # at slot +0x08): CREATEMATCH and PLAYGAME append nothing, MATCHREADY `/ready`,
    # DESTROYMATCH `/end`, RESETMATCH `/reset`, KEEPALIVE `/keepalive`.
    #
    # THERE IS NO /match/{id} URL ANYWHERE. The id travels in the body. So the old
    # `re.search(r"/match/(\d+)")` could never match a real request, and the reward
    # path was gated on DELETE-or-/ut/delete/ which the client also never sends --
    # it would have fired on nothing. match_id is retained only for hand probes.
    #
    # The HTTP VERB for each call cannot be determined statically: the strings "PUT"
    # and "DELETE" do not exist anywhere in cardsdll.dll (0 hits each), so verb
    # selection happens in the HTTP layer outside this DLL. Hence: match on the PATH
    # and accept any verb. A reviewer specifically flagged the claim "the reward path
    # can never fire" as overreach on exactly this point, since the verb is unknown
    # rather than known-wrong, so this widens the gate instead of replacing it.
    is_delete = (h.command == "DELETE" or "/ut/delete/" in h.path
                 or (MATCH_END and h.path.split("?")[0].endswith("/match/end")))

    if is_delete:
        # FutDestroyMatch -- the ONLY place a match awards anything.
        result, score = _match_result(body)
        coins = MATCH_COINS.get(result, 0) + MATCH_PARTICIPATION
        rec, total = STORE.record_match(result, coins)
        log("    MATCH: %s%s -> +%d coins (total %d)  record %d-%d-%d"
            % (result, (" %d-%d" % score) if score else "", coins, total,
               rec["won"], rec["draw"], rec["loss"]))
        return 200, destroy_match_body(result, coins, total)

    if h.command == "POST" and match_id is None:
        # FutCreateMatch. `squad` is nested + freeze-risky -> omitted (SKIP-safe).
        mid = STORE.new_item_id()
        log("    MATCH: created id=%d" % mid)
        return 200, {"startDateTime": int(datetime.datetime.now().timestamp()),
                     "reportIdEnabled": False, "id": mid}

    # PUT {id} = MatchReady, POST {id} = PlayGame. Both have NO deserializer at
    # all, so {} is a complete response; the result is claimed on destroy.
    if body:
        log("    MATCH: %s %s body=%s" % (h.command, h.path, json.dumps(body)[:400]))
    return 200, {}


def squad_route(h):
    # PUT = SaveCurrentSquad. FutSquadSaveServerResponse deser 0x180171a60 parses
    # exactly ONE key, id(0x15c) -> reply {"id": <squadId>}, NOT an echo of the
    # squad (the echo's extra keys were merely SKIP'd, but the id was only present
    # by luck of the client's own body).
    if h.command == "PUT":
        try:
            sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
        except Exception:
            sq = None
        if isinstance(sq, dict) and sq.get("players"):
            sq.setdefault("id", 0)
            STORE.save_squad(sq)
            log("    SQUAD: saved squad id=%s (%d slots)" % (sq["id"], len(sq["players"])))
            return 200, {"id": sq["id"]}
        # Malformed/empty PUT: still answer with the id we hold, never {} -- the
        # save handler needs the key and an empty body reads as squadId 0.
        return 200, {"id": current_squad().get("id", 0)}

    # GET = LoadActiveSquad: the full squad object, directly (no wrapper). Never
    # return {} -- an empty body resets the 23 slots (0x18013d1f0).
    sq = current_squad()
    if _SQUAD_LIST_MODE == "merged":
        sq = dict(sq, **squad_list_body(sq))
    return 200, sq


# ---- STORE / PACKS (first-cut; iterate against the log) ---------------------
# FUT_PRICE_PROBE: step 1 of the pack live test, and the control the rest of it rests
# on. Sends `funds` and `finalFunds` as different numbers for the Gold Pack alone, so
# the tile reveals which key the client renders and, more importantly, whether the
# store body reaches the tile at all.
#
# OFF BY DEFAULT and it must go back off after the test: this puts a price on a real
# tile that the buy path does not charge. The pack still DEBITS p["price"] (5000), so
# a purchase made with this on leaves the tile and the wallet disagreeing by 679 coins.
# That is harmless for one run and confusing forever if it is left on.
#
# Enable for the test with:  FUT_PRICE_PROBE=1 ./openfut-fut.sh restart
_PRICE_PROBE = os.environ.get("FUT_PRICE_PROBE", "0") == "1"
# Which pack carries the probe. Env-driven because WHICH TILE IS REACHABLE is not
# something we control: observed live 2026-08-05, all three packs collapse into a single
# display group (we send displayGroup but never displayGroupAssetId 0xda, so they all
# share group 0), and drilling into any group renders one representative, Premium Gold.
# A probe on an unreachable tile answers nothing, so this has to be movable.
_PROBE_PACK_ID = int(os.environ.get("FUT_PRICE_PROBE_PACK", "5"))
_PROBE_FINAL_FUNDS = 4321   # not a round number FUT could plausibly have chosen itself


def _probe_final_funds(p):
    """finalFunds for one pack tile. Identical to funds unless the probe is armed."""
    if _PRICE_PROBE and p.get("id") == _PROBE_PACK_ID:
        return _PROBE_FINAL_FUNDS
    return p["price"]


def _pack_body(p, idx):
    """One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).

    THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
    corrections" broke pack BUYING live, and now confirmed to be the right call for a
    reason nobody had at the time.

    THE FUT_STORE_FIELDS BLOCK WAS A FREEZE, NOT A REGRESSION. It has been DELETED.
    It sent `"actionType": 0` and `"firstPartyStoreId": 0` as JSON integers, and both
    atoms (0x8 and 0x127) are read with the STRING getter 0x1801c7aa0. That is the
    exact type-desync class this whole project exists to avoid: a scalar of the wrong
    token type where the parser calls a typed getter, which is what spins the reader
    at 0x1801c7f1a. So "the corrections stopped packs opening" was not bad luck or an
    unrelated field; two of them were the documented freeze mechanism, shipped by a
    change whose own comment said it was correct about what the parser reads.

    "Parsed" is not "safe to change", and knowing WHICH atoms a parser reads tells you
    nothing about which TYPES it demands. Read the getter, every time.

    Two of the deleted block's other fields were no-ops anyway: `useDefaultImage`
    (0x36a) stores inverted, so True set it false, and `visible` (0x37d) never reads
    its value at all (`local_130 = 1` unconditionally).
    """
    gold = p["gold"]
    mtx = max(1, p["price"] // 100)
    body = {
        "assetId":       p["id"],
        "id":            p["id"],
        "packType":      "GOLD" if gold else "BRONZE",
        "description":   p["name"],
        "state":         "active",
        "saleType":      "promo",
        "limitType":     "NONE",
        "quantity":      0,
        "purchaseLimit": 0,
        "purchaseCount": 0,
        "isPremium":     False,
        "sortPriority":  idx,
        # FUT_PRICE_PROBE: the step-1 CONTROL of the 2026-08-05 pack live test.
        # `funds` and `finalFunds` are sent as DIFFERENT numbers for one pack only, so
        # the tile tells us which of the two the client renders, and whether the store
        # body reaches the tile at all. Without this a silent Quick Sell in step 4 is
        # ambiguous between "Quick Sell sends nothing" and "our store response never
        # arrived". Default OFF: it is a wrong price on a real tile, so it must not
        # linger past the test. See docs/plan-2026-08-05-pack-opening.md section 7.
        # finalFunds 4321 is deliberately not a round number FUT could have chosen.
        "currencies":    [{"name": "coins", "funds": p["price"],
                           "finalFunds": _probe_final_funds(p)}],
        "extPrice":      {"finalPrice":    {"amount": mtx, "currency": "mtx"},
                          "originalPrice": {"amount": mtx, "currency": "mtx"}},
        "packContentInfo": {
            "bronzeQuantity": 0 if gold else p["count"],
            "silverQuantity": 0,
            "goldQuantity":   p["count"] if gold else 0,
            "rareQuantity":   p["count"] if gold else 0,
            "itemQuantity":   p["count"],
            "unopened":       False,
        },
    }
    if STORE_DISPLAYGROUP:
        # THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
        # it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
        # recursion the old notes assumed does not exist and there was never anything
        # to terminate. Exactly two members live inside it: value(0x377, STRING) and
        # priority(0x250, INT).
        #
        # `value` writes record offset +0x00, which is the SAME slot whose constructor
        # default is the literal "unknown" (the only such literal in cardsdll.dll,
        # 0x180223108, written by FUN_180133f60). That is why the tiles read "unknown":
        # not a missing translation, just a field nobody ever sent.
        #
        # ONE KEY, distinct per pack so grouping stays 1:1 and the current one-tile-
        # per-pack layout is preserved. priority(0x250), displayGroupAssetId(0xda) and
        # displayGroupUseDefaultImage(0xdb) are all OMITTED: each is a second variable,
        # and 0xdb stores inverted so the old True was always a no-op.
        #
        # Parser-side this is provably balanced: the store root 0x1801234e0 consumes
        # each element's START_OBJECT, so case 0xd9 dispatches with the value token
        # current, and {"value": "..."} consumes FIELD_NAME, string scalar,
        # END_OBJECT. Nothing left over, nothing over-consumed.
        #
        # WHAT THE PROOF DOES NOT COVER, and it is the real risk: this may be the first
        # field we have ever sent that selects a RENDER PATH rather than a value.
        # Going from no-displayGroup to a-displayGroup could switch FIFA17.exe from an
        # ungrouped-tile layout to a group-tile layout, and that code is packed. If the
        # tiles collapse into one, or render blank because `value` is a loc key rather
        # than a caption, the store goes from ugly-but-working to unusable. Default OFF
        # for exactly that reason, and the live test buys a pack to prove the buy path
        # still works.
        body["displayGroup"] = {"value": p["name"]}
        # FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
        # sending displayGroup did switch the store to a grouped render path, all three
        # packs collapsed into ONE group, and drilling into any of the three group tiles
        # rendered the same single Premium Gold pack. Two of three packs became
        # unbuyable. Cosmetic tile names were bought with two thirds of the store.
        #
        # displayGroupAssetId (0xda) is the obvious thing to group BY, and it is real:
        # case 0xda in 0x18013af30 calls the INT getter 0x1801c79d0 and lands in the
        # 0x158-byte pack record at +0x30 (the record is copy-constructed out of the
        # stack frame at the tail of the deser, via FUN_1801340e0 / FUN_180132180).
        # Omitting it presumably leaves every pack on the same default, hence one group.
        #
        # This is a hypothesis with a mechanism, not a proven fix. The consumer that
        # builds group membership was NOT located: it is reached from the packed
        # FIFA17.exe side and chasing it costs far more than the live test does.
        # Type fidelity is not the risk here (a scalar into an INT getter is the safe
        # direction; the freeze that started all this came from sending displayGroup as
        # an ARRAY where a flat object was expected), so the cheap experiment is sound.
        #
        # Default OFF until a launch shows three separately buyable tiles.
        # If it does NOT work, the correct fallback is FUT_STORE_DISPLAYGROUP=0, which
        # restores the ungrouped layout: tiles read "unknown" but all three are buyable.
        # Ugly and working beats pretty and unbuyable.
        if STORE_GROUPID:
            body["displayGroupAssetId"] = p["id"]
    return body


def store_catalog(h):
    """GET ut/%s/store/purchasegroup/... -- FutStoreGetPackTypes (root 0x1801234e0).

    The "unknown" tiles are addressed by FUT_STORE_DISPLAYGROUP (see _pack_body).

    THE FUT_STORE_GROUPS BLOCK IS DELETED, and its failure is now fully explained. It
    sent displayGroup as an ARRAY of pack-shaped objects, on the belief that the key
    was parsed recursively by this same element parser. It is not: it is a flat object
    with two members. An array there desyncs the reader, the parser then runs off the
    end of the document, and the tokenizer returns the same token forever with nothing
    consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
    0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
    """
    packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)]
    return 200, {"purchase": packs, "timestamp": 1596326400}


def store_buy(h):
    # PUT (v2) store/transaction. The BUY is the create step: body carries "packId"
    # (atom 0x20b, TRANSACTIONCREATED) and state != TRANSACTIONCANCEL (wf a245577b /
    # wf_76fcf89b). Only THEN open a pack. Cancel/other -> no-op {} (fixes the
    # phantom-buy). Reveal = {"createPackResponse":{itemList,numberItems,
    # purchasedPackId,duplicateItemIdList}} (FutCreatePackServerResponse 0x180162880).
    try:
        body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
    except Exception:
        body = {}
    pid = body.get("packId")
    if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
        return 200, {}                              # not a confirmed buy
    pack = pack_by_id(pid)
    if not pack:
        return 200, {}
    items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
                            pack.get("tiers"))
    if items is None:
        return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
    log("    STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins()))
    return 200, {"createPackResponse": {
        "itemList":            items,
        "numberItems":         len(items),
        "purchasedPackId":     pid,
        "duplicateItemIdList": [],
    }}


def purchased_items(h):
    # POST = FutPurchaseItemsServerResponse: the BUY itself. Live ground truth: FIFA
    # sends {"packId":1,"useCredits":1,"usePreOrder":0,"currency":"COINS"} then
    # immediately polls GET /purchased/items for the awarded cards. Open the pack
    # here (debit coins, award items). Response mirrors CardsDLL's serializer
    # (0x180126900): packId, firstPartyStoreId, groupName, productId,
    # purchasePackType -- unknown keys are skipped, so extra fields are harmless.
    # GET = FutGetPurchasedItemsServerResponse: the awarded items from the last buy.
    if h.command == "POST":
        try:
            body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
        except Exception:
            body = {}
        pid = body.get("packId")
        pack = pack_by_id(pid) if isinstance(pid, int) else None
        if pack is None:
            return 200, {"itemData": STORE.last_pack()}
        items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
                            pack.get("tiers"))
        if items is None:
            return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
        log("    STORE: POST /purchased opened pack %s -> %d items, coins=%d"
            % (pack["name"], len(items), STORE.coins()))
        if PACK_AUTOCLUB:
            # Move straight to the club so the reveal never offers a hand-off.
            moved = STORE.move_items([{"id": it["id"], "pile": "club"} for it in items])
            log("    STORE: auto-club deposited %d card(s) (reveal hand-off bypassed)"
                % len(moved))
        return 200, {
            "packId":           pid,
            "firstPartyStoreId": 0,
            "groupName":         "fifa17",
            "productId":         str(pack["id"]),
            "purchasePackType":  "GOLD" if pack["gold"] else "BRONZE",
        }
    return 200, {"itemData": STORE.last_pack()}


def credits_route(h):
    # The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
    # atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
    c = STORE.coins()
    return 200, {
        "credits": c,
        "currencies": [
            {"name": "coins",  "funds": c, "finalFunds": c},
            {"name": "points", "funds": 0, "finalFunds": 0},
        ],
    }


# ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ----------------
# Every list response shares one body: {auctionInfo:[record], credits, total,
# duplicateItemIdList} (shared deser 0x18013e7f0). auctionInfo + dupIdList MUST be
# ARRAYS and each record.itemData MUST be a card OBJECT, or the SAX reader desyncs
# -> busy-loop freeze at 0x1801c7f1a. We serve an EMPTY-but-valid market (no live
# listings yet): empty arrays never desync, so this is freeze-safe. Populating real
# auctions needs an in-game test pass. Extra keys are SKIP'd, so one merged body
# safely satisfies both the search parser and the auction-count parser.
# Sample auction listings (real players from the pack pool) so the market is
# browsable/buyable. Each record follows the reversed auction schema (deser
# 0x18013e410) EXACTLY; itemData reuses fut_store._item -- the same proven-safe
# card shape that renders club/squad cards (parser 0x18013fe00). All record
# fields are HIGH-confidence reversed scalars, so freeze risk is low. Toggle with
# FUT_MARKET=empty. Price heuristic: rating-based buy-now, ~66% starting bid.
_MARKET_MODE = os.environ.get("FUT_MARKET", "sample")
_TRADE_ID_BASE = 900000000


def _price_for(rating):
    if rating >= 90: return 25000
    if rating >= 85: return 8000
    if rating >= 80: return 2500
    if rating >= 75: return 900
    return 400


def _auction_record(i, defn):
    asset, rating, pos, nation, league, team, attrs = defn
    buy = _price_for(rating)
    card = _item(_TRADE_ID_BASE + 100000 + i, asset, rating, pos, nation, league, team, attrs)
    card["untradeable"] = False           # market cards are tradeable
    card["itemState"] = "forSale"
    return {
        "tradeId":           _TRADE_ID_BASE + i,
        "itemData":          card,                       # OBJECT (0x18013fe00) -- freeze-safe
        "tradeState":        "active",                   # enum string
        "buyNowPrice":       buy,
        "startingBid":       max(150, (buy * 2) // 3),
        "currentBid":        0,
        "bidState":          "none",                     # enum string
        "expires":           3600,                       # SECONDS remaining (not epoch)
        "sellerName":        "EASFC",
        "sellerEstablished": 1,
        "watched":           False,
        "coinsProcessed":    0,
    }


def _market_auctions(limit=21):
    if _MARKET_MODE == "empty":
        return []
    return [_auction_record(i, PACK_POOL[i % len(PACK_POOL)])
            for i in range(min(limit, len(PACK_POOL)))]


def _auction_by_tradeid(tid):
    # tradeId space is _TRADE_ID_BASE + index into PACK_POOL -> reconstruct the auction.
    i = tid - _TRADE_ID_BASE
    if 0 <= i < len(PACK_POOL):
        return _auction_record(i, PACK_POOL[i])
    return None


def _market_body(auctions):
    return {"auctionInfo": auctions, "credits": STORE.coins(),
            "total": len(auctions), "duplicateItemIdList": []}


def auctionhouse_route(h):
    # GET  = search (sample listings) OR count  -> merged body (extra keys skip)
    # POST = FutISStart (list item for sale)    -> {"id": new tradeId}
    # PUT  = .../relist (relist all expired)    -> ack {}
    if h.command == "POST":
        # FutISStart: list an owned club item for sale -> {"id": tradeId}
        try:
            b = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
        except Exception:
            b = {}
        item_id = (b.get("itemData") or {}).get("id") or b.get("itemId")
        if item_id:
            tid = STORE.list_for_sale(item_id, b.get("startingBid", 150), b.get("buyNowPrice", 0))
            log("    MARKET: listed item %s -> tradeId %d" % (item_id, tid))
            return 200, {"id": tid}
        return 200, {"id": STORE.new_item_id()}
    if h.command == "PUT":
        return 200, {}
    body = _market_body(_market_auctions())
    body.update({"count": 0, "maxAuctionsAllowed": 100,
                 "offered": 0, "selling": 0, "sold": 0})   # FutGetAuctionCount ints
    return 200, body


def trade_route(h):
    # GET  view one auction        -> {auctionInfo:[record], credits}
    # POST/PUT place bid / buy-now -> stateful: on buy-now (bid >= buyNowPrice) deduct
    #   coins, grant the won card to the club, echo the CLOSED auction. Reuses the
    #   validated auction-record shape (0x18013e410) so it's freeze-safe; whether FIFA
    #   surfaces the won item post-buy is functional (needs live test). FutISOfferTrade.
    m = re.search(r"/trade/(\d+)", h.path)
    tid = int(m.group(1)) if m else -1
    rec = _auction_by_tradeid(tid)
    if h.command in ("POST", "PUT"):
        try:
            body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
        except Exception:
            body = {}
        if rec is None:
            return 200, {"auctionInfo": [], "credits": STORE.coins()}
        bid = body.get("bid") or rec["buyNowPrice"]
        if bid >= rec["buyNowPrice"]:                      # BUY NOW
            if not STORE.spend(rec["buyNowPrice"]):
                return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
            won = dict(rec["itemData"]); won.pop("id", None)
            won["itemState"] = "free"                      # unassigned/won
            granted = STORE.add_items([won])[0]
            rec = dict(rec)
            rec.update({"tradeState": "closed", "bidState": "highest",
                        "currentBid": rec["buyNowPrice"], "itemData": granted})
            log("    MARKET: bought tradeId %d for %d, coins=%d, card->club"
                % (tid, rec["currentBid"], STORE.coins()))
        else:                                              # simple bid (we're sole bidder)
            rec = dict(rec); rec.update({"currentBid": bid, "bidState": "highest"})
        return 200, {"auctionInfo": [rec], "credits": STORE.coins()}
    return 200, {"auctionInfo": [rec] if rec else [], "credits": STORE.coins()}


def tradepile_route(h):
    # The user's OWN sale pile: build a validated auction record per active listing
    # from the owned club item + its list prices. Freeze-safe (same record shape).
    by_id = {it["id"]: it for it in STORE.items()}
    seller = ACCOUNT.persona_name
    recs = []
    for l in STORE.listings():
        it = by_id.get(l["itemId"])
        if not it:
            continue
        card = dict(it); card["itemState"] = "listFS"
        recs.append({
            "tradeId": l["tradeId"], "itemData": card, "tradeState": "active",
            "buyNowPrice": l.get("buyNowPrice", 0), "startingBid": l.get("startingBid", 150),
            "currentBid": 0, "bidState": "none", "expires": 3600,
            "sellerName": seller, "sellerEstablished": 1, "watched": False, "coinsProcessed": 0,
        })
    return 200, {"auctionInfo": recs, "credits": STORE.coins(), "total": len(recs)}


def delete_trade_route(h):
    # DELETE ut/delete/game/fifa17/trade/{id} -- remove a listing from the sale pile.
    m = re.search(r"/trade/(\d+)", h.path)
    if m:
        STORE.remove_listing(int(m.group(1)))
    return 200, {}


def watchlist_route(h):
    if h.command in ("PUT", "POST", "DELETE"):
        return 200, {}                                     # add/remove watch -> ack
    return 200, {"auctionInfo": [], "credits": STORE.coins(), "total": 0}


class H(http.server.BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"

    def _handle(self):
        n = int(self.headers.get("Content-Length", 0) or 0)
        body = self.rfile.read(n) if n else b""
        self._body = body                          # route fns (squad PUT) read this
        log("%s %s" % (self.command, self.path))
        for k, v in self.headers.items():
            log("    %s: %s" % (k, v))
        if body:
            log("    body: %s" % body[:65536].decode("utf-8", "replace"))

        code, payload = 200, {}
        for rx, fn in ROUTES:
            if rx.search(self.path):
                code, payload = fn(rx, self)
                break
        else:
            log("    !! UNMAPPED PATH -> catch-all 200 {}")

        raw = b"" if payload is None else json.dumps(payload).encode()
        self.send_response(code)
        if raw:
            self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        if self.command != "HEAD" and raw:
            self.wfile.write(raw)
        log("  -> %d %s" % (code, raw[:200].decode() if raw else "(no body)"))

    do_GET = do_POST = do_PUT = do_DELETE = do_HEAD = do_PATCH = _handle

    def log_message(self, *a):
        pass


if __name__ == "__main__":
    open(LOG, "a").close()
    log("=== utas_server http://%s:%d ===" % ADDR)
    http.server.ThreadingHTTPServer(ADDR, H).serve_forever()
