diff --git a/fifa17-recon/tools/test_card_families.py b/fifa17-recon/tools/test_card_families.py new file mode 100644 index 0000000..8dbaaef --- /dev/null +++ b/fifa17-recon/tools/test_card_families.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Offline regression tests for the non-player card families. + +WHY THIS IS A SEPARATE SUITE. tools/test_fut_contract.py talks to a LIVE server over +HTTP and imports nothing from the server's own code -- that is what lets it certify a +future non-Python implementation of the same reversed spec. These checks are the +opposite kind: they are unit tests of the item BUILDERS, they must run against the +working tree rather than against whatever process happens to be listening on 8099, and +they must not require the live client to be restarted. Mixing them into the contract +suite would have broken both properties. + +Run: python3 tools/test_card_families.py (exit 0 = all pass, stdlib only) +""" +import os, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) + +_fail = [] +_pass = 0 + + +def check(name, cond, detail=""): + global _pass + if cond: + _pass += 1 + else: + _fail.append("%s %s" % (name, detail)) + + +def raises(name, fn, *a, **kw): + try: + fn(*a, **kw) + except Exception: + check(name, True) + return + check(name, False, "did NOT raise") + + +# ---------------------------------------------------------------- the rareflag trap +def test_item_rareflag_trap(): + """fut_store._item() must not stamp rareflag 1 on a Player Fitness card. + + FUN_1801bfac0 case 5 takes the SQUAD-fitness branch when + `(cardsubtypeid == 0xdc) || (*(int *)(rec + 0x58) == 1)`, and rec+0x58 IS the + rareflag atom 0x271. So a rare 219 renders as a Squad Fitness card -- a silent + corruption of the first fitness card we would ever serve. + """ + import fut_store + d = fut_store._item(100000001, 20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80]) + # 1. THE PLAYER DICT IS BYTE-IDENTICAL TO BEFORE THE FIX. Key order included: + # every existing caller passes 8 positional args, so both new keyword params + # take their defaults. + check("player item key ORDER unchanged", + list(d) == ["id", "resourceId", "assetId", "cardassetid", "definitionId", + "cardsubtypeid", "itemType", "rareflag", "rating", + "preferredPosition", "nation", "teamid", "leagueId", "playStyle", + "attributeList", "itemState", "owners", "untradeable", + "contract", "fitness"], repr(list(d))) + check("player item VALUES unchanged", + d == {"id": 100000001, "resourceId": 20801, "assetId": 20801, + "cardassetid": 20801, "definitionId": 20801, "cardsubtypeid": 0, + "itemType": "player", "rareflag": 1, "rating": 94, + "preferredPosition": "LW", "nation": 38, "teamid": 243, "leagueId": 53, + "playStyle": 250, + "attributeList": [{"index": i, "value": v} for i, v in + enumerate([90, 93, 82, 91, 33, 80])], + "itemState": "free", "owners": 1, "untradeable": True, + "contract": 7, "fitness": 99}, repr(d)) + # 2. THE GUARD ITSELF. This is the assertion that fails without the fix. + f = fut_store._item(1, 5002001, 55, "LW", 0, 0, 0, [0] * 6, + cardsubtypeid=219, rareflag=1) + check("subtype 219 forced to rareflag 0 (the squad-fitness trap)", + f["rareflag"] == 0, repr(f["rareflag"])) + check("subtype 219 keeps its cardsubtypeid", f["cardsubtypeid"] == 219, + repr(f["cardsubtypeid"])) + # 3. THE GUARD IS SCOPED. It must not touch any neighbouring subtype. + for sub in (218, 220, 4, 5, 0): + g = fut_store._item(1, 1, 50, "LW", 0, 0, 0, [0] * 6, + cardsubtypeid=sub, rareflag=1) + check("subtype %d keeps rareflag 1 (guard is 219-only)" % sub, + g["rareflag"] == 1, repr(g["rareflag"])) + + +# ------------------------------------------------------------------- consumables +def test_consumables(): + import fut_consumables as fc + + check("172 cardtype-6 subtypes", len(fc.SUBTYPES) == 172, len(fc.SUBTYPES)) + dead = [r for r in fc.SUBTYPES if r["kind"] == "DEAD_ZONE"] + check("28 dead zones", len(dead) == 28, len(dead)) + check("dead zones are exactly the known set", + sorted(r["cardsubtypeid"] for r in dead) == + [58, 59, 60, 68, 69, 70, 87, 88, 89, 90, 111, 112, 113, 114, 115, 116, 117, + 118, 119, 120, 203, 204, 205, 206, 207, 208, 209, 210]) + # A dead-zone card renders as a plausible Squad Training (Pace) card with amount + # 0 -- there is no DB-Error analogue -- so the builder must refuse it outright. + raises("consumable_item refuses a dead zone (89)", fc.consumable_item, 1, 89) + raises("consumable_item refuses a non-cardtype-6 subtype", fc.consumable_item, 1, 4) + # Omitting `amount` stamps (byte)-1 into rec+0xbf, and FUN_1801a8040 sign-extends, + # so the card reads "-1" rather than 0. Refuse rather than ship that. + raises("training without amount is refused", fc.consumable_item, 1, 61) + raises("healing without amount is refused", fc.consumable_item, 1, 211) + raises("contract without contract= is refused", fc.consumable_item, 1, 201) + raises("rare Player Fitness (219) is refused", fc.consumable_item, 1, 219, + amount=20, rareflag=1) + # 220 is ALWAYS squad fitness (0xdc is the first half of the branch test), so a + # rare one is merely rare, not corrupted. + check("rare Squad Fitness (220) is allowed", + fc.consumable_item(1, 220, amount=10, rareflag=1)["rareflag"] == 1) + + shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE) + check("starter shelf is non-empty", len(shelf) > 0, len(shelf)) + check("no dead zone on the shelf", + not [i for i in shelf + if fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] == "DEAD_ZONE"]) + check("shelf ids are unique", len({i["id"] for i in shelf}) == len(shelf)) + check("shelf ids are clear of the save's 1e8 space", + all(i["id"] >= fc.CONSUMABLE_ID_BASE for i in shelf)) + check("no 219 on the shelf is rare", + all(i["rareflag"] == 0 for i in shelf if i["cardsubtypeid"] == 219)) + for i in shelf: + r = fc.BY_SUBTYPE[i["cardsubtypeid"]] + if "amount" in r["needs"]: + check("subtype %d carries amount" % i["cardsubtypeid"], "amount" in i) + if "contract" in r["needs"]: + check("subtype %d carries contract" % i["cardsubtypeid"], "contract" in i) + # Player-only fields must never appear: rec+0x146 and rec+0x98.. survive and + # are read by the generic view-model. + check("subtype %d sends no player-only fields" % i["cardsubtypeid"], + not ({"preferredPosition", "attributeList", "nation", "leagueId", + "teamid", "playStyle", "fitness"} & set(i)), repr(sorted(i))) + # The excluded families, each for a named reason (see CORE_KINDS). + kinds = {fc.BY_SUBTYPE[i["cardsubtypeid"]]["kind"] for i in shelf} + for banned in ("manager_formation_mod", "formation_mod", "manager_league"): + check("%s is NOT shipped" % banned, banned not in kinds) + # ?type= routing + check("type=contract -> only categories 2/3", + {fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"] + for i in fc.items_for_type("contract")} <= {2, 3}) + check("type=training -> only category 0", + {fc.BY_SUBTYPE[i["cardsubtypeid"]]["category"] + for i in fc.items_for_type("training")} == {0}) + check("an unknown type gets nothing", fc.items_for_type("player") == []) + check("def_for resolves an EA carddbid", fc.def_for(5001001) is not None) + check("def_for returns None for a player asset", fc.def_for(20801) is None) + + +# ----------------------------------------------------------------- coach families +def test_coaches(): + import fut_coaches as cc + + check("four coach families", len(cc.FAMILIES) == 4) + for fam, (sub, ct, table, miss) in cc.FAMILIES.items(): + rows = cc.COACHES[fam] + check("%s row count" % fam, + len(rows) == {"headcoach": 124, "gkcoach": 121, "physio": 51, + "fitnesscoach": 115}[fam], len(rows)) + # The oracle: rating 0x32 can ONLY ever be a miss, in every family. + check("%s has no row with value 50 (the miss-fill rating)" % fam, + not [r for r in rows if r["rating"] == 50]) + check("%s ids are all < 2^24 (the key is a raw u32 but the artwork masks)" % fam, + all(r["carddbid"] < (1 << 24) for r in rows)) + check("%s carddbids are unique" % fam, + len({r["carddbid"] for r in rows}) == len(rows)) + # fitness coach's miss triple must not exist as a real row, or its second oracle + # would be ambiguous. + check("no fitnesscoach row is (fieldpos 1, posbonus 7, amount 1)", + not [r for r in cc.COACHES["fitnesscoach"] + if (r["fieldpos"], r["posbonus"], r["amount"]) == (1, 7, 1)]) + # tier() is the binary's own tail, not a convention. + check("tier boundaries", (cc.tier(64), cc.tier(65), cc.tier(74), cc.tier(75)) + == (1, 2, 2, 3)) + + raises("coach_item refuses a non-coach subtype", cc.coach_item, 1, 4, 1000509) + for miss in cc.MISS_FILL_IDS: + raises("coach_item refuses miss-fill assetid %d" % miss, + cc.coach_item, 1, 5, miss) + + seeds = cc.starter_coaches(cc.COACH_ID_BASE) + check("24 starter coaches (six per family)", len(seeds) == 24, len(seeds)) + check("starter ids unique", len({i["id"] for i in seeds}) == len(seeds)) + for i in seeds: + check("starter coach %d is a real row" % i["resourceId"], + (i["cardsubtypeid"], i["resourceId"]) in cc.BY_ID) + check("starter coach %d has a non-zero id (no id -> NO merge at all)" + % i["resourceId"], i["id"] != 0) + check("coach %d sends no invented nation/league/team" % i["resourceId"], + not ({"nation", "leagueId", "teamid", "preferredPosition", + "attributeList", "rating", "rareflag", "assetId"} & set(i)), + repr(sorted(i))) + subs = {i["cardsubtypeid"] for i in seeds} + check("all four families represented", subs == {5, 6, 7, 8}, repr(sorted(subs))) + + # ?type= routing: each family's own arm serves only that family; staff (arm 10) + # serves all four. + for fam, (sub, _ct, _tbl, _miss) in cc.FAMILIES.items(): + got = cc.items_for_type(fam) + check("type=%s serves only subtype %d" % (fam, sub), + got and {i["cardsubtypeid"] for i in got} == {sub}, + repr({i["cardsubtypeid"] for i in got})) + check("type=staff serves all four families", + {i["cardsubtypeid"] for i in cc.items_for_type("staff")} == {5, 6, 7, 8}) + check("an unknown type gets nothing", cc.items_for_type("player") == []) + # Ids must not shift depending on which arm asked, or the same card would enter + # the client's CardsDb map twice under two handles. + check("item ids are stable across arms", + {i["resourceId"]: i["id"] for i in cc.items_for_type("staff")} == + {i["resourceId"]: i["id"] + for fam in cc.FAMILIES for i in cc.items_for_type(fam)}) + + +# --------------------------------------------------------------------- managers +def test_managers(): + import fut_staff as fs + + check("417 manager cards", len(fs.MANAGERS) == 417, len(fs.MANAGERS)) + check("carddbid == assetid band 1000001..1001552", + (fs.MANAGERS[0]["carddbid"], fs.MANAGERS[-1]["carddbid"]) == (1000001, 1001552)) + # The `manager` join: 747 rows but 746 distinct managerids -- managerid 107 is + # duplicated with an empty-name row, which used to win the dict comprehension. + check("297 managers carry a real name (the 107 duplicate resolved)", + sum(1 for m in fs.MANAGERS if m["name"]) == 297, + sum(1 for m in fs.MANAGERS if m["name"])) + check("carddbid 1000107 keeps Slutskiy, not the empty row", + fs.BY_ID[1000107]["name"] and fs.BY_ID[1000107]["teamid"] == 315, + repr(fs.BY_ID[1000107])) + check("ten starter managers", len(fs.STARTER_MANAGERS) == 10) + for c in fs.STARTER_MANAGERS: + m = fs.BY_ID[c] + check("starter manager %d has a name" % c, bool(m["name"])) + check("starter manager %d has nation/league/team" % c, + m["nation"] and m["leagueId"] and m["teamid"]) + it = fs.manager_item(1, 1000509) + check("manager resourceId is the RAW carddbid (the merge does not mask)", + it["resourceId"] == 1000509) + check("manager subtype 4", it["cardsubtypeid"] == 4) + check("manager sends nation/leagueId (rec+0xde/+0xe0 are OURS alone)", + it["nation"] == 45 and it["leagueId"] == 53, repr(it)) + check("manager sends no rating/rareflag/position/attrs (all overwritten or read)", + not ({"rating", "rareflag", "preferredPosition", "attributeList", + "assetId", "definitionId"} & set(it)), repr(sorted(it))) + + +# ------------------------------------------------------- overlay id-space hygiene +def test_id_spaces(): + """The four id spaces must not overlap: a collision would make two different cards + share an item id, and the client keys its CardsDb map on it.""" + import fut_consumables as fc, fut_coaches as cc, fut_staff as fs + spaces = { + "save": (100000000, 100999999), + "consumable": (fc.CONSUMABLE_ID_BASE, fc.CONSUMABLE_ID_BASE + 999999), + "coach": (cc.COACH_ID_BASE, cc.COACH_ID_BASE + 999999), + "manager": (fs.OVERLAY_ID_BASE, fs.OVERLAY_ID_BASE + 999999), + "probe": (fs.PROBE_ID_BASE, fs.PROBE_ID_BASE + 999999), + "sweep": (900000000, 900999999), + } + names = sorted(spaces) + for i, a in enumerate(names): + for b in names[i + 1:]: + lo1, hi1 = spaces[a] + lo2, hi2 = spaces[b] + check("%s and %s id spaces are disjoint" % (a, b), + hi1 < lo2 or hi2 < lo1, "%r %r" % (spaces[a], spaces[b])) + + +def main(): + for t in (test_item_rareflag_trap, test_consumables, test_coaches, test_managers, + test_id_spaces): + try: + t() + except Exception as e: + _fail.append("%s raised %s: %s" % (t.__name__, type(e).__name__, e)) + print("\n%d checks passed, %d failed" % (_pass, len(_fail))) + for f in _fail: + print(" FAIL:", f) + return 0 if not _fail else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 001cfef..4a8d077 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -578,6 +578,17 @@ PLAYER_DEFS = { 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])) @@ -990,11 +1001,25 @@ ROUTES = [ 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 i.get("itemType") == "player"]) + 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} @@ -1113,7 +1138,7 @@ def _club_stat_context(kind): 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 i.get("itemType") == "player"] + 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 @@ -1141,7 +1166,7 @@ def _club_stat_context(kind): 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 i.get("itemType") == "player"] + 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 @@ -1149,14 +1174,22 @@ def _club_stat_set(): 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. - ("staff", 0), + # 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), @@ -1167,11 +1200,11 @@ def _club_stat_set(): # 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", 0), # 0xb - ("staffHeadCoach", 0), # 0xc - ("staffGKCoach", 0), # 0xd - ("staffPhysio", 0), # 0xe - ("staffFitnessCoach", 0), # 0xf + ("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 @@ -1414,6 +1447,87 @@ def sweep_items(): 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. +CONSUMABLES = os.environ.get("FUT_CONSUMABLES", "") +COACHES = os.environ.get("FUT_COACHES", "") +MANAGERS = os.environ.get("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 @@ -1449,6 +1563,7 @@ def club_route(h): # 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: @@ -1457,18 +1572,40 @@ def club_route(h): 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))) if kind and kind not in ("player", "custom"): # cardsubtypeid 0..3 is a player (FUN_1800d8330); everything else is - # staff or a manager. We own no staff cards yet, so this is [] today -- + # 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))) - elif CLUB_PAGE: - log(" CLUB: returning %d item(s) [FUT_CLUB_PAGE experiment -- compare this " - "number against the MY CLUB counter on screen]" % 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}