Files
OpenFUT/scripts/sold-client-ports.py
T
funman300 aa5fb2cc40 test(market): make the sold A/B one-field attributable, add classified differential
The brief's gate: if the harness varies bidState AND coinsProcessed together, the
client's reaction is attributable to neither. The env knobs were already orthogonal
(--variant and --coins-processed are independent, cp defaults to 0), but
sold-wire-check.py was flipping BOTH for variant B as a convenience, which is exactly
the contaminated A/B the brief forbids. Fixed: the primary pair now holds
coinsProcessed at 0 and asserts the differing-field set is exactly ['bidState'].

New scripts/sold-ab-differential.py is the pre-live gate. It settles ONE synthetic
sale, then re-reads every seller-facing surface under each variant by restarting only
the host (same Core, same DBs, same sale), and diffs with explicit classification --
MISSING / EXTRA / TYPE_MISMATCH / VALUE_MISMATCH -- rather than a boolean "equal?".
Two orthogonal pairs:

  PRIMARY     bidState highest vs buyNow, coinsProcessed held at 0
  ORTHOGONAL  coinsProcessed 0 vs 1,      bidState held at highest

Result, 36/36: the ONLY finding on /tradePile is
VALUE_MISMATCH auctionInfo[0].bidState A='highest' B='buyNow'; /trade/status differs
in exactly the same one path; counts are byte-identical. The orthogonal pair's only
finding is auctionInfo[0].coinsProcessed. C_cp0's sha256 equals A_highest's, so the
capture is reproducible rather than merely consistent.

Counts states the live run has to interpret, measured not guessed:
  S1  0 active + 1 sold -> count 0, selling 0, sold 1
  S2  1 active + 1 sold -> count 1 (active mode) vs 2 (membership mode)
That divergence IS the open question for the client; production is unchanged.

scripts/sold-client-ports.py switches ONLY the two client Blaze port lines, and is
built so restoration cannot depend on memory: it records the production values to a
sidecar on the client BEFORE the first edit and restore reads that sidecar, refusing
if it is absent. It rewrites only known keys (a missing key is an error, never a
silent append), re-reads and verifies afterwards, and REFUSES to edit while a FIFA
client is running because the hook reads the file at connect time.

Phase 0 evidence under docs/evidence/sold-ab-2026-08-18/ with a sha256 per surface,
one file per variant so A can never overwrite B.

Live client A/B NOT run: a production FIFA session is currently live on 10.10.0.105
(pid 32188), and live-session mutual exclusion applies. The client config was NOT
touched -- the switcher's guard refused, as designed.

Production untouched: prod-host pid 3631953, coins 29,843,976, /tradePile 0,
counts.sold 0, club 1966; nothing under /home/alex/openfut-promotion/state/ opened.
2026-08-18 02:22:12 +00:00

128 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Switch the FIFA client's Blaze ports between PRODUCTION and STAGING, reversibly.
The staging sold experiment needs the client to talk to the staging Blaze (which
advertises the staging UTAS base). The ONLY client change required is the two Blaze
port lines in `openfut.cfg`; `host` and `https_port` are left alone.
Safety properties, in order of importance:
* The production values are recorded to a sidecar file on the client BEFORE the
first edit, and `restore` reads that sidecar rather than assuming what
production was. If the sidecar is missing, restore refuses.
* `restore` is idempotent and safe to run at any time, including after a crash.
* Every operation re-reads the file afterwards and prints it, so the result is
verified rather than assumed.
* It refuses to edit while a FIFA client is running: the hook reads this file at
connect time, so changing it under a live session is unsafe.
* Only the two known keys are rewritten. Unknown lines are passed through
untouched, and a missing key is an error rather than a silent append.
python3 scripts/sold-client-ports.py show
python3 scripts/sold-client-ports.py staging # 42327 / 42330
python3 scripts/sold-client-ports.py restore # back to the recorded values
"""
import subprocess
import sys
CLIENT = "alex@10.10.0.105"
CFG = "/mnt/games/FIFA 17/openfut.cfg"
SIDECAR = "/mnt/games/FIFA 17/openfut.cfg.openfut-prod-ports"
KEYS = ("blaze_redirector_port", "blaze_main_port")
STAGING = {"blaze_redirector_port": "42327", "blaze_main_port": "42330"}
def ssh(script):
r = subprocess.run(["ssh", "-o", "BatchMode=yes", CLIENT, script],
capture_output=True, text=True, timeout=60)
if r.returncode != 0:
raise SystemExit(f"ssh failed ({r.returncode}): {r.stderr.strip()}")
return r.stdout
def read_cfg():
return ssh(f'cat "{CFG}"')
def parse(text):
out = {}
for line in text.splitlines():
if "=" in line and not line.strip().startswith("#"):
k, _, v = line.partition("=")
out[k.strip()] = v.strip()
return out
def fifa_running():
return bool(ssh("pgrep -f FIFA17.exe || true").strip())
def show():
text = read_cfg()
print(f"--- {CFG}")
print(text.rstrip())
cur = parse(text)
print("--- blaze ports:", {k: cur.get(k) for k in KEYS})
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
print("--- recorded production ports:", side or "(none recorded yet)")
print("--- FIFA client running:", "YES" if fifa_running() else "no")
return cur
def write_ports(values, label):
if fifa_running():
raise SystemExit(
"REFUSING to edit openfut.cfg while a FIFA client is running.\n"
"The hook reads this file at connect time; exit FIFA first."
)
text = read_cfg()
cur = parse(text)
for k in KEYS:
if k not in cur:
raise SystemExit(f"key {k!r} is absent from {CFG}; refusing to guess it")
# Record production values once, before the first mutation, so restore never
# has to assume what they were.
existing_sidecar = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
if not existing_sidecar and label == "staging":
rec = "\n".join(f"{k}={cur[k]}" for k in KEYS)
ssh(f'cat > "{SIDECAR}" <<\'EOF\'\n{rec}\nEOF')
print(f"recorded production ports to {SIDECAR}:\n{rec}")
out = []
for line in text.splitlines():
k = line.partition("=")[0].strip()
out.append(f"{k}={values[k]}" if k in values else line)
body = "\n".join(out) + "\n"
ssh(f'cat > "{CFG}" <<\'EOF\'\n{body.rstrip()}\nEOF')
after = parse(read_cfg())
for k, v in values.items():
if after.get(k) != v:
raise SystemExit(f"VERIFY FAILED: {k} is {after.get(k)!r}, expected {v!r}")
print(f"--- now pointing at {label}")
print(read_cfg().rstrip())
def main():
if len(sys.argv) != 2 or sys.argv[1] not in ("show", "staging", "restore"):
print(__doc__)
return 2
cmd = sys.argv[1]
if cmd == "show":
show()
return 0
if cmd == "staging":
write_ports(STAGING, "staging")
return 0
side = ssh(f'cat "{SIDECAR}" 2>/dev/null || true').strip()
if not side:
raise SystemExit(
f"no recorded production ports at {SIDECAR}; refusing to guess.\n"
"Set them by hand and verify with `show`."
)
write_ports(parse(side), "production (restored)")
return 0
if __name__ == "__main__":
sys.exit(main())