aa5fb2cc40
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.
128 lines
4.5 KiB
Python
Executable File
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())
|