de747b79e6
The operator could not field a starting XI because staging has only ever held the 14-item synthetic fixture (11 auto-picked starters, one disposable, two kits). The real club -- the 1986-item CAGE import, 29,843,976 coins -- was never lost, but it sits in `/home/alex/openfut-promotion/state`, which BOTH staging lifecycle scripts list in FORBIDDEN_PATHS and refuse to open. That guard is correct and stays. Worth recording while looking for the club: the LIVE production Core container serves an EMPTY database (0 owned cards, schema predating even the game_id column). The real club is not being served anywhere right now; it exists as state on disk. So restoring it into staging is not a convenience, it is the only way to play it. Two pieces: `scripts/club-snapshot.py` is the ONE place allowed to read production state, and it is read-only by construction: the Core database is opened `mode=ro` and copied with sqlite's online backup API (a plain file copy can tear a database with a hot WAL), every destination is asserted to be outside the production directory before anything is opened for writing, and the sha256 of every source is compared before and after -- a mismatch aborts, because that would mean the snapshot modified production. It then proves the copy is faithful (same counts, coins, squad) and that the identity store maps EVERY owned card to a wire id, since an unmapped card would reappear under a freshly minted id and break the client's cached squad. `sold-staging-up.py --club real` installs that snapshot. It is installed BEFORE Core first starts, so Core migrates the copy forward from schema v19 through match_completions, squad managers and kit assignments. Seller A is then already present -- it IS the imported persona -- so only Buyer B is seeded, the kit fixtures are attached to the real club so the kit work stays exercisable, and the real squad is left alone. The up script still never reads production state: the snapshot lives outside it, which is precisely what makes `--club real` compatible with the `safe_path()` refusal. The resolvability preflight now covers whichever club will actually be served. This is the check that matters most for the real one: Core does not fail on an owned card whose definition is missing, it silently filter_map-drops it, so a gap shows up as an EMPTY club with all 1986 rows still in the database. Verified: all 1712 distinct card ids resolve in both the content pack and the identity catalog, 0 missing. `sold-staging-seed-squad.py` now REFUSES to run when the manifest says the real club is installed. `PUT /squad/0` is a full replacement, so the fixture seeder would have overwritten the operator's own lineup with an auto-picked XI -- destructive and not recoverable in place. `--show` still works in every mode; `--force` overrides. Verified end to end against the restored club: /club 29,843,976 coins, /collection 1988, 1966 players + 2 kits served over the UTAS wire, squad 'OpenFUT' (f433) rated 90 with 11 players carrying contract 7 / fitness 99, and every wire id stable from the snapshot identity store. The fixture path was re-run afterwards and still seeds exactly 14 items, so the SOLD experiment is unaffected.
223 lines
10 KiB
Python
Executable File
223 lines
10 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
|
|
|
|
This is a FIXTURE tool. `PUT /squad/0` is a FULL REPLACEMENT, so running it against
|
|
the operator's real imported club would overwrite that club's own squad with an
|
|
auto-picked XI -- a destructive, unrecoverable-in-place edit of real data. It
|
|
therefore refuses to run when the staging manifest says the real club is installed,
|
|
unless `--force` is given. `--show` stays available in every mode.
|
|
"""
|
|
import http.client
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
STAGING_MANIFEST = "/home/alex/openfut-sold-staging/manifest.json"
|
|
|
|
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 refuse_on_real_club():
|
|
"""`PUT /squad/0` replaces the whole squad. Against the real club that destroys
|
|
the operator's own lineup, so the fixture seeder must not be runnable there by
|
|
accident -- the staging manifest records which club is installed."""
|
|
if "--force" in sys.argv:
|
|
print(" --force: seeding over the installed club as instructed")
|
|
return
|
|
try:
|
|
with open(STAGING_MANIFEST) as fh:
|
|
manifest = json.load(fh)
|
|
except FileNotFoundError:
|
|
return # no manifest: nothing claims a real club is installed
|
|
if manifest.get("club") != "real":
|
|
return
|
|
club = manifest.get("real_club") or {}
|
|
raise SystemExit(
|
|
"REFUSING: staging is running the operator's REAL club "
|
|
f"({club.get('username')}, {club.get('owned_cards')} items) and "
|
|
"PUT /squad/0 is a FULL REPLACEMENT -- this would overwrite the real squad "
|
|
f"({club.get('squad_players')} players) with an auto-picked XI.\n"
|
|
" Read it instead: python3 scripts/sold-staging-seed-squad.py --show\n"
|
|
" Override only if you mean it: --force"
|
|
)
|
|
|
|
|
|
def main():
|
|
print("=== staging squad, before ===")
|
|
before = show()
|
|
if "--show" in sys.argv:
|
|
return 0
|
|
refuse_on_real_club()
|
|
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())
|