70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
283 lines
14 KiB
Python
283 lines
14 KiB
Python
#!/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())
|