diff --git a/scripts/sold-staging-seed-squad.py b/scripts/sold-staging-seed-squad.py new file mode 100755 index 0000000..1afe8e5 --- /dev/null +++ b/scripts/sold-staging-seed-squad.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Seed the staging seller a valid starting XI so the FIFA hub will open. + +The sold-row A/B only ever needed the tradePile wire, so the staging identity was +created with owned items but NO squad. Every headless check passed because none of +them asks for a squad -- but the real client refuses to enter the FUT hub with an +empty one ("squad update error"), because a squad is a hub precondition, not a +Transfer-List detail. + +This seeds it through the REAL route -- `PUT /ut/game/fifa17/squad/0`, the same +request the client itself sends -- so the squad is written by +parse_squad_put/build_squad_write exactly as a genuine save would be. Writing +Core/DB rows by hand would risk a shape the live path would never produce, which is +the sort of divergence that invalidates an experiment. + +Wire facts (openfut-adapter-fifa17/src/fut/squad.rs): the players array is a fixed 23 +slots; 0..=10 are the pitch, 11..=22 bench/reserves, derived from the INDEX alone and +never from the formation token. Empty slots are `itemData.id == 0`. The formation +token is carried verbatim and never interpreted server-side. A successful save +answers `{"id": 0}` and does NOT echo the squad. + + python3 scripts/sold-staging-seed-squad.py # seed, then verify + python3 scripts/sold-staging-seed-squad.py --show # read-only +""" +import http.client +import json +import sys + +HOST, PORT = "127.0.0.1", 8299 +FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216} +HEADERS = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"} +SLOTS = 23 +STARTERS = 11 +# 4-3-3, laid out in the conventional FIFA slot order so the pitch reads correctly: +# GK, RB, CB, CB, LB, CM, CM, CM, RW, ST, LW. +LINEUP = ["GK", "RB", "CB", "CB", "LB", "CM", "CM", "CM", "RW", "ST", "LW"] +FORMATION = "f433" + + +def call(method, path, body=None): + assert PORT not in FORBIDDEN, "refusing to touch a production port" + c = http.client.HTTPConnection(HOST, PORT, timeout=20) + payload = json.dumps(body) if body is not None else None + c.request(method, path, body=payload, headers=HEADERS) + r = c.getresponse() + raw = r.read() + c.close() + try: + return r.status, json.loads(raw) + except Exception: + return r.status, raw.decode(errors="replace") + + +def club_players(): + status, body = call("GET", "/ut/game/fifa17/club?count=200") + assert status == 200, f"/club -> {status}" + items = body.get("itemData", body.get("items", [])) + return [i for i in items if i.get("itemType") == "player"] + + +def pick_xi(players): + """Choose one owned player per lineup position, best rating first, without + reusing an instance. A position with no candidate is reported rather than + quietly left empty -- an incomplete XI is why the hub errored in the first + place, so silently reproducing it would defeat the point.""" + remaining = sorted(players, key=lambda p: -int(p.get("rating") or 0)) + chosen, missing = [], [] + for want in LINEUP: + hit = next((p for p in remaining if p.get("preferredPosition") == want), None) + if hit is None: + hit = next((p for p in remaining), None) # any spare body, flagged below + if hit is not None: + missing.append(f"{want}->{hit.get('preferredPosition')}") + if hit is None: + missing.append(f"{want}->NONE") + chosen.append(None) + continue + remaining.remove(hit) + chosen.append(hit) + return chosen, missing + + +def build_put(chosen): + players = [] + for idx in range(SLOTS): + occupant = chosen[idx] if idx < STARTERS and chosen[idx] else None + players.append({ + "index": idx, + "itemData": {"id": int(occupant["id"]) if occupant else 0, "dream": False}, + "kitNumber": idx + 1 if occupant else 0, + }) + captain = next((int(p["id"]) for p in chosen if p), 0) + return { + "id": 0, + "squadName": "Staging XI", + "formation": FORMATION, + "captain": captain, + "players": players, + "manager": [], + "kicktakers": [], + } + + +def show(): + status, body = call("GET", "/ut/game/fifa17/squad/0") + filled = [p for p in body.get("players", []) if p.get("itemData", {}).get("id")] + print(f" /squad/0 -> {status} id={body.get('id')} " + f"players={len(body.get('players', []))} occupied={len(filled)}") + for p in filled: + d = p.get("itemData", {}) + print(f" index={p.get('index')} id={d.get('id')} res={d.get('resourceId')} " + f"pos={d.get('preferredPosition')} rating={d.get('rating')}") + return len(filled) + + +def main(): + print("=== staging squad, before ===") + before = show() + if "--show" in sys.argv: + return 0 + players = club_players() + print(f"=== owned players available: {len(players)} ===") + chosen, missing = pick_xi(players) + if missing: + # Loud, because an out-of-position XI still opens the hub but is not what a + # real save would look like. + print(f" NOTE: substitutions made for {len(missing)} slot(s): {missing}") + if any(c is None for c in chosen): + raise SystemExit("not enough owned players for a starting XI; refusing to " + "write a partial squad") + put = build_put(chosen) + print(f"=== PUT /squad/0 formation={put['formation']} captain={put['captain']} " + f"occupied={sum(1 for p in put['players'] if p['itemData']['id'])} ===") + status, body = call("PUT", "/ut/game/fifa17/squad/0", put) + print(f" -> {status} {json.dumps(body)}") + if status != 200 or body != {"id": 0}: + raise SystemExit(f"squad save did not acknowledge as {{'id':0}}: {status} {body}") + print("=== staging squad, after ===") + after = show() + if after != STARTERS: + raise SystemExit(f"VERIFY FAILED: expected {STARTERS} occupied slots, got {after}") + print(f" OK: {before} -> {after} occupied slots; the hub precondition is satisfied") + return 0 + + +if __name__ == "__main__": + sys.exit(main())