0f83d73364
Round 3, 10 agents. The headline is measured, not inferred: GET club/stats/consumables
is requested 41 times per session by the real client (ProtoHttp), and _club_stat_set()
answers it with the PLAYER stat set. The panel reads 14 consumables* names that we
have never sent, so it is told '205 players' when it asked how many contracts the club
owns, and it has nothing to show.
That also explains why last round's 126-item consumable shelf was never requested. It
serves type=contract|training|healing|development and an UNTYPED /club with no
team=/league=, and all 9 of the client's untyped requests this session carry team=. The
only +126 item(s) line in the whole log came from one of our own probes.
Vocabulary recovered: the 14 consumables* rows plus badgeDBid 0x2e, kitsHome 0x29,
kitsAway 0x2a, leagueLogos 0x2f, trophiesSeasonOnline 0x38.
Other measured surfaces the client asks for and we fob off: GET /settings 11x answered
with an empty config array (a 40-flag feature gate, the biggest untouched lever in the
project), leaderboards/options 5x with {}, user/accountinfo 4x with {}.
club/stats/staff is a DIFFERENT class (FutStaffBonus); the staff counts come from the
Stats2 store, which is why the staff screen worked while we answered {}.
Refuted: ENDPOINT_MAP's claim that objectives have no route. FUN_180151610 builds
<base>/objective/%d/reward and FUN_180147780 builds .../complete.
New modules only. utas_server.py is deliberately untouched: whether to wire the counts
depends on a free observation the human can make on the client that is already running,
and spending a restart before that is what this round exists to avoid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
220 lines
9.2 KiB
Python
220 lines
9.2 KiB
Python
#!/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())
|