#!/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" SQUAD_TYPE = "REGULAR_SQUAD" CHEMISTRY = 50 # Production's squad-manager reference, mirrored verbatim; it resolves to nothing in # production either (absent from its own /club/staff listing), which is exactly why # copying it is safe. PRODUCTION_MANAGER_REF = 100000427 # Production's opaque 33-int tactics array. Never parsed server-side; reused because # only the shape (a JSON-encoded int array, not null) matters to the client. CUSTOM = ("[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0," "50,50,0,50,40,65,0,65,50,50,1]") 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) ratings = [int(p.get("rating") or 0) for p in chosen if p] mean_rating = sum(ratings) // len(ratings) if ratings else 0 return { "id": 0, "squadName": "Staging XI", "formation": FORMATION, "captain": captain, "players": players, # A first attempt sent only name/formation/captain/players and the client # STILL refused the hub, while the host logged squad-active 200 ok -- the # route was fine and the body was not. Diffing against production's # known-good squad showed staging returning null for exactly these five, # because the PUT never carried them and the extension stored nothing. # Types matter here: the client wants scalars, not null. "squadType": SQUAD_TYPE, "chemistry": CHEMISTRY, "rating": mean_rating, "starRating": mean_rating, # Opaque 33-int tactics array, carried verbatim and never interpreted # server-side. Reused from production because only its SHAPE matters. "custom": CUSTOM, # Production carries five, all pointing at one player. Mirrored so the # array is populated rather than empty. "kicktakers": [{"index": i, "id": captain, "dream": False} for i in range(5)], # Mirrors production's manager reference verbatim, including the fact that # the id resolves to nothing. # # This looked unfixable at first: production points at instance 100000427 # while the staging club holds 11 players and zero staff, so there was # apparently no manager to reference. Then checking production properly # showed 100000427 is absent from its OWN club listing too -- # /club/staff returns 1975 items spanning ids 100000001..100004826 and # 100000427 is not one of them. Production's manager reference is dangling # and the client accepts that squad regardless, which proves the client does # not validate the manager id against the club. Only a populated array # matters, so replicating the known-good state exactly is both faithful and # sufficient -- and it beats pointing the manager slot at a player. "manager": [{"id": PRODUCTION_MANAGER_REF, "dream": False}], } 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())