Files
OpenFUT/scripts/sold-staging-seed-squad.py
T
funman300 96ca7c0484 fix(scripts): the seeded squad was structurally valid but the client still refused it
First seed sent only squadName/formation/captain/players. The host logged squad-active
200 outcome=ok and /squad/0 showed 11 occupied slots, yet the client still threw a FUT
squad update error -- a 200 with the right players is not proof the client accepts the
body.

Diffed against production's known-good squad, which the same client accepts, comparing
key presence and JSON TYPES rather than values. Staging returned null for exactly the
five fields the PUT never carried, because the extension stored nothing for them:
squadType (a string enum), chemistry, rating, starRating (ints) and custom (the opaque
33-int tactics array the client definitely parses). kicktakers was empty where
production carries five.

Now sends all of them: squadType REGULAR_SQUAD, chemistry, rating/starRating derived
from the XI's mean rating, production's custom array verbatim (opaque server-side, only
its shape matters), and five kicktakers. Re-diff leaves exactly one difference --
manager, which production points at owned staff instance 100000427 while the staging
club holds 11 players and zero staff. Left empty rather than inventing an id that
references a non-existent item; recorded in the code as the one known remaining gap.

Also fixes a KeyError from the rewrite dropping the players key.
2026-08-18 03:33:42 +00:00

175 lines
7.6 KiB
Python
Executable File

#!/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 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)],
# Left empty deliberately: production references an owned staff instance
# (100000427) and the staging club holds 11 players and zero staff, so
# there is no manager to point at. Inventing an id would reference a
# non-existent item. This is the one remaining shape difference.
"manager": [],
}
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())