fifa17-python: commit working FUT backend deployment (client/server split)
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.
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""READ-ONLY checker for the club/stats vocabulary change.
|
||||
|
||||
Nothing here mutates a save, opens a pack, or writes an item. It issues GETs
|
||||
only, and every GET it issues is one the client already issues by itself.
|
||||
|
||||
Run it BEFORE the integrator lands the consumables rows (it will report the
|
||||
14 rows missing, which is the current, wrong state) and AFTER (it should report
|
||||
all clear). It is deliberately NOT part of test_fut_contract.py /
|
||||
test_card_families.py -- those two have fixed baselines (439 / 414) that must
|
||||
not move.
|
||||
|
||||
python3 check_club_stat_vocab.py [--host 127.0.0.1:8099]
|
||||
|
||||
WHAT IT CHECKS, and why each check exists
|
||||
-----------------------------------------
|
||||
1. SPELLING. The 14 consumables* type strings and all 40 vocabulary names are
|
||||
asserted against docs/fut_atoms.tsv. An unrecognised `type` string is not an
|
||||
error on the client: FUN_18012fd40 returns stat id 0 and the row lands in a
|
||||
bucket nothing reads. A typo therefore fails SILENTLY to zero and looks
|
||||
exactly like "the hypothesis was wrong". This is the highest-risk detail in
|
||||
the whole change, so it is checked first and off the atom table, not off a
|
||||
transcription.
|
||||
2. THE THREE DEAD ATOMS. consumablesContract 0xa6 / consumablesTraining 0xa7 /
|
||||
consumablesFitness 0xa8 are real atom names with NO arm in FUN_18012fd40.
|
||||
Sending them proves nothing and lands in bucket 0 under stat id 0. Assert we
|
||||
never send them.
|
||||
3. PURELY ADDITIVE. Every global `type` the server sends today must still be
|
||||
sent, with the same value, after the change. The player/manager/coach panels
|
||||
are live-proven and ride on those rows.
|
||||
4. SHAPE. All four keys in every element (element-local vars are not reset
|
||||
between elements, so a missing key silently inherits the previous element's
|
||||
value), every value an int, contextId 1 for the global bucket.
|
||||
5. THE COUNTS ARE REACHABLE AT ALL. If FUT_CONSUMABLES is armed, the 14 rows
|
||||
must sum to the size of the shelf the server would actually serve. All
|
||||
fourteen at zero while the shelf is armed is the specific failure this whole
|
||||
round is trying to avoid: the club STORE holds no consumables (the shelf is a
|
||||
GET-time overlay), so counting the store alone yields fourteen zeros and an
|
||||
uninterpretable live test.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DOCS = os.path.join(os.path.dirname(HERE), "docs")
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
# stat id -> atom id, for the fourteen rows the CONSUMABLES panel (FUN_180043b90
|
||||
# case 6) reads out of bucket 0. Both columns are cross-checked below: the name
|
||||
# against docs/fut_atoms.tsv, the stat id against fut_club_stats.VOCAB.
|
||||
CONSUMABLE_ATOMS = {
|
||||
0x3C: 0xA5, 0x41: 0xAF, 0x42: 0xA9, 0x43: 0xB3, 0x44: 0xAB,
|
||||
0x45: 0xB2, 0x46: 0xB5, 0x47: 0xAA, 0x48: 0xAD, 0x49: 0xB4,
|
||||
0x4A: 0xAC, 0x4B: 0xB0, 0x4C: 0xB1, 0x4D: 0xAE,
|
||||
}
|
||||
|
||||
# Real atom names with no arm in FUN_18012fd40 -> stat id 0 -> dropped.
|
||||
DEAD_NAMES = ("consumablesContract", "consumablesTraining", "consumablesFitness")
|
||||
|
||||
PASS, FAIL = [], []
|
||||
|
||||
|
||||
def ok(msg):
|
||||
PASS.append(msg)
|
||||
|
||||
|
||||
def bad(msg):
|
||||
FAIL.append(msg)
|
||||
|
||||
|
||||
def load_atoms():
|
||||
path = os.path.join(DOCS, "fut_atoms.tsv")
|
||||
atoms = {}
|
||||
with open(path, encoding="utf8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
p = line.rstrip("\n").split("\t")
|
||||
if len(p) >= 3:
|
||||
atoms[p[2]] = int(p[1], 16)
|
||||
return atoms
|
||||
|
||||
|
||||
def get(base, path):
|
||||
url = "http://%s/ut/game/fifa17%s" % (base, path)
|
||||
with urllib.request.urlopen(url, timeout=20) as r:
|
||||
return json.loads(r.read().decode() or "{}")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--host", default="127.0.0.1:8099")
|
||||
args = ap.parse_args()
|
||||
|
||||
import fut_club_stats as fcs
|
||||
|
||||
# ---- 1 / 2 spelling, off the atom table ------------------------------
|
||||
atoms = load_atoms()
|
||||
for sid, atom in sorted(CONSUMABLE_ATOMS.items()):
|
||||
name = fcs.VOCAB.get(sid)
|
||||
if name is None:
|
||||
bad("VOCAB has no name for stat id 0x%02x" % sid)
|
||||
elif atoms.get(name) != atom:
|
||||
bad("0x%02x %r: atom %s, expected %s"
|
||||
% (sid, name, hex(atoms.get(name) or 0), hex(atom)))
|
||||
else:
|
||||
ok("0x%02x %-42s atom %s" % (sid, name, hex(atom)))
|
||||
unknown = [n for n in fcs.VOCAB.values() if n not in atoms]
|
||||
if unknown:
|
||||
bad("VOCAB names absent from fut_atoms.tsv: %s" % unknown)
|
||||
else:
|
||||
ok("all %d vocabulary names resolve in fut_atoms.tsv" % len(fcs.VOCAB))
|
||||
if "leaguelogos" in fcs.VOCAB.values():
|
||||
bad("lowercase leaguelogos (0x18d) is NOT in the map; use leagueLogos (0x18e)")
|
||||
else:
|
||||
ok("leagueLogos capitalisation correct")
|
||||
|
||||
# ---- live bodies ------------------------------------------------------
|
||||
try:
|
||||
bodies = {m: get(args.host, "/club/stats/" + m)
|
||||
for m in ("year", "consumables", "country/14", "league/13")}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print("cannot reach the server at %s: %s" % (args.host, exc))
|
||||
return 2
|
||||
|
||||
for mode, body in bodies.items():
|
||||
rows = body.get("stat", [])
|
||||
if not rows:
|
||||
bad("%s: empty stat body" % mode)
|
||||
continue
|
||||
glob = [r for r in rows if r.get("contextId") == 1]
|
||||
|
||||
# ---- 4 shape ----------------------------------------------------
|
||||
wrong_keys = [r for r in rows
|
||||
if set(r) != {"contextId", "contextValue", "type", "typeValue"}]
|
||||
wrong_type = [r for r in rows
|
||||
if not isinstance(r.get("typeValue"), int)
|
||||
or not isinstance(r.get("contextValue"), int)]
|
||||
if wrong_keys:
|
||||
bad("%s: %d elements do not carry all four keys" % (mode, len(wrong_keys)))
|
||||
elif wrong_type:
|
||||
bad("%s: %d elements carry a non-int value" % (mode, len(wrong_type)))
|
||||
else:
|
||||
ok("%-12s %3d rows, all four keys, all ints" % (mode, len(rows)))
|
||||
|
||||
if rows[0].get("type") != "players":
|
||||
bad("%s: first row is %r, not players -- club_stats_route logs "
|
||||
"stats[0]['typeValue'] as the player count" % (mode, rows[0].get("type")))
|
||||
|
||||
# ---- 2 dead atoms ------------------------------------------------
|
||||
sent = {r["type"] for r in glob}
|
||||
for n in DEAD_NAMES:
|
||||
if n in sent:
|
||||
bad("%s: sends dead atom %s (no arm in FUN_18012fd40)" % (mode, n))
|
||||
|
||||
# ---- the fourteen -------------------------------------------------
|
||||
want = {fcs.VOCAB[s] for s in CONSUMABLE_ATOMS}
|
||||
miss = sorted(want - sent)
|
||||
if miss:
|
||||
bad("%s: %d of the 14 consumables rows MISSING: %s"
|
||||
% (mode, len(miss), ", ".join(miss)))
|
||||
else:
|
||||
ok("%-12s carries all 14 consumables rows" % mode)
|
||||
|
||||
# ---- 3 purely additive ----------------------------------------------
|
||||
ref = {r["type"]: r["typeValue"]
|
||||
for r in bodies["year"]["stat"] if r.get("contextId") == 1}
|
||||
for mode in ("consumables", "country/14", "league/13"):
|
||||
cur = {r["type"]: r["typeValue"]
|
||||
for r in bodies[mode]["stat"] if r.get("contextId") == 1}
|
||||
drift = {k: (v, cur.get(k)) for k, v in ref.items() if cur.get(k) != v}
|
||||
if drift:
|
||||
bad("%s: global rows disagree with year: %s" % (mode, drift))
|
||||
if not any("disagree with year" in f for f in FAIL):
|
||||
ok("the global bucket is identical across all four modes")
|
||||
|
||||
for k in ("players", "playersGold", "playersSilver", "playersBronze",
|
||||
"rarePlayers", "staff"):
|
||||
if k not in ref:
|
||||
bad("live-proven row %r is no longer being sent" % k)
|
||||
|
||||
# ---- 5 the counts are reachable at all -------------------------------
|
||||
total = ref.get("consumables")
|
||||
if total is None:
|
||||
ok("(consumables total not sent yet -- pre-change state)")
|
||||
else:
|
||||
leaves = sum(ref.get(fcs.VOCAB[s], 0)
|
||||
for s in CONSUMABLE_ATOMS if s != 0x3C)
|
||||
if total != leaves:
|
||||
bad("consumables total %d != sum of the 13 leaves %d" % (total, leaves))
|
||||
else:
|
||||
ok("consumables total %d == sum of the leaves" % total)
|
||||
try:
|
||||
import fut_consumables as fc
|
||||
shelf = len(fc.starter_consumables(fc.CONSUMABLE_ID_BASE))
|
||||
except Exception: # noqa: BLE001
|
||||
shelf = None
|
||||
if shelf and total == 0:
|
||||
bad("all 14 rows are ZERO while fut_consumables would serve %d items. "
|
||||
"The shelf is a GET-time OVERLAY and is NOT in the club STORE, so "
|
||||
"counting STORE.items() alone yields fourteen zeros -- see "
|
||||
"_staff_overlay_counts() for the pattern the staff rows already use."
|
||||
% shelf)
|
||||
elif shelf and total != shelf:
|
||||
bad("consumables total %d != shelf size %d" % (total, shelf))
|
||||
elif shelf:
|
||||
ok("consumables total matches the %d-item shelf" % shelf)
|
||||
|
||||
print("\n".join(" ok " + m for m in PASS))
|
||||
if FAIL:
|
||||
print("\n".join(" FAIL " + m for m in FAIL))
|
||||
print("\n%d ok, %d failed" % (len(PASS), len(FAIL)))
|
||||
return 1 if FAIL else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user