fifa17-recon: serve consumables, coaches and managers behind flags, default OFF
Wires the three families into club_route as a synthetic OVERLAY. Nothing changes by
default: with all three flags unset every route returns byte-identical bodies, checked
against the live 194-item save (club 194, type=player 194, league=53 drill-down 67,
hub clubPlayers 194, every staff stat 0).
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
WHY AN OVERLAY AND NOT A GRANT. Clearing the flag restores the real club exactly on
the next fetch, with no un-granting and no edit to a save a live client is holding
open. And Store.add_items() uses setdefault("id", ...), so items arriving with an id
already set do NOT advance nextItemId -- granting these would eventually collide two
id spaces. The four overlay bases (9.4e8 consumables, 9.5e8 coaches, 9.6e8 managers,
9.1e8 probe) are asserted disjoint from each other, from the save's 1e8 and from the
sweep's 9e8. The cost is that overlay cards cannot be quick-sold or moved.
WHY EACH FLAG IS TWO-VALUED. The tab-to-?type= binding is UNOBSERVED -- only
type=player, type=manager and type=custom have ever come from this client, and none of
the nine other arm names in FUN_18012ec50 has. So every club fetch now LOGS the arm it
was asked for while any flag is set. That is what makes an empty tab actionable: it
says whether the request reached us and under which name, instead of nothing.
THE MIRROR FILTER, and it is not optional. club_route applied its cardsubtypeid filter
only when `kind and kind not in ("player","custom")`. For type=player, for type=custom
(the by-league / by-team drill-downs) and for an untyped fetch it filtered NOTHING, so
the moment the club held a non-player item it would be served straight into the
players tab and the drill-downs -- and 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. The player branch now filters cardsubtypeid in (0,1,2,3). Provable
no-op today: all 194 items in the save are cardsubtypeid 0.
Same hardening for the three counters that keyed on itemType == "player" (hub
clubPlayers, _club_stat_context, _club_stat_set) -> _is_player(), i.e. the CLIENT's own
definition from FUN_1800d8330. itemType 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.
FREE SECOND ORACLE: the five staff sub-type counters (0xb..0xf) are now computed from
the overlay. FUN_180094ce0's STAFF_EMPLOYED row is the +0x800 SUM over those five, so
the parent id 0xa alone could never move it. That number moves without the merge being
involved at all, which keeps "our club reports N staff" separable from "the client
resolved the card".
item_def() also learns consumables (gated): answering a consumable resourceId with
cardsubtypeid 0 makes it cardtype 0 -- no merge arm, no miss-fill, i.e. plausible
garbage -- and it carried the same hardcoded rareflag 1 as fut_store._item().
tools/test_card_families.py: 414 offline checks over the item BUILDERS. Deliberately a
separate suite -- test_fut_contract.py talks to a live server over HTTP and imports
nothing from the server's own code, which is what lets it certify a future non-Python
implementation; these must run against the working tree without restarting anything.
Each guard was mutation-checked: reverting the 219 fix, or letting coach_item accept a
miss-fill assetid, or letting consumable_item accept a dead zone, each fails the suite.
Suites after: test_fut_contract 439/0, test_match_rewards 61/0, test_card_families
414/0. utas_server was NOT restarted; this lands on disk only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
@@ -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())
|
||||
@@ -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}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user