feat(market): isolated two-identity SOLD-row A/B harness (staging only, not promoted)

Static RE exhausted CardsDLL on the one open question: for a closed row
IS_GLOW = (bidState != none) and INBOX = (bidState in {highest, buyNow}), so
closed/highest and closed/buyNow are BIT-IDENTICAL natively. But bidState is
published to the movie verbatim as YOURBID, so the FUT ActionScript CAN separate
them. This builds the controlled experiment that asks the client which one it
treats as the seller's sale.

PRODUCTION SAFETY IS THE FIRST CONCERN
New module openfut-utas-host/src/sold_experiment.rs. Every knob is OFF unless its
env var is set, an unrecognised value is OFF rather than a default token (silently
picking one would fabricate the answer being measured), and the host logs a startup
banner naming the active variant so a staging capture can never be mistaken for a
production one. With no env set, /tradePile and /trade/status emit only real active
auctions (the Fix A invariant) and counts still report sold: 0. The entire existing
test suite now passes SoldExperiment::OFF explicitly, making it a regression guard.

  OPENFUT_FIFA17_SOLD_EXPERIMENT      = highest | buyNow   (else OFF)
  OPENFUT_FIFA17_SOLD_COINS_PROCESSED = 1                  (else 0)
  OPENFUT_FIFA17_SOLD_COUNT_MODE      = active_plus_sold    (else active)

WHAT THE EXPERIMENT PROJECTS
Uncleared sold listings appear in /tradePile and /trade/status as tradeState
"closed" with the token under test and currentBid = the sale price; counts report
the real sold tally. There is ONE record builder, so the A/B changes only what is
passed into it, and a test asserts that EXACTLY ONE field differs between the two
variants -- without that control the client's reaction is not attributable to the
token and the whole experiment is void. coinsProcessed (Flash COINS_AWARDED) varies
independently so the third pass cannot be confounded with the first.

CLEAR-SOLD, PE-PROVEN
New EconomyRoute::MarketClearSold for DELETE .../trade/sold, classified BEFORE the
generic trade cancel arm -- a `sold` tail carries no id, so the cancel handler would
have parsed nothing and acked while clearing nothing. Builder 0x1801647c0 emits
"/sold" when the tradeId field is zero and "/%lld" otherwise; the client calls it
RemoveAllSoldFromTradePile. New market-store column cleared_at records the seller's
acknowledgement SEPARATELY from the sale, so clearing can never be mistaken for
re-settling: it is presentation only, moves no coins and no ownership, and is
idempotent for client retries.

FOUND AND FIXED A LATENT STORE BUG
Adding a column via the additive ALTER path immediately after CREATE TABLE in the
same open() desynced sqlx's per-connection schema cache: a fresh store then read a
12-column row while metadata said 13, panicking a pool worker with an index
out-of-bounds and silently returning zero listings. Declaring cleared_at in
CREATE_LISTINGS fixes it; the ALTER now only serves pre-existing stores. This would
have bitten the next column too.

STAGING, WITHOUT TOUCHING PRODUCTION
The client learns the UTAS base from BLAZE (blaze_responder_v3b.py:646 hardcodes
:8099), and it dials that port directly, so redirecting UTAS means changing Blaze or
port 8099 -- both production. 10.10.0.121 is unreachable. The compliant path is a
parallel stack on spare ports plus a one-line change to the CLIENT's own config:
  * scripts/sold-staging-up.py / sold-staging-down.py -- staging Core 18081,
    utas-host 8299, Blaze 42327/42330/42331 advertising :8299, two seeded identities,
    own DBs under /home/alex/openfut-sold-staging/. Patches a COPY of the Blaze
    responder and asserts every substitution applied, so a silent no-op cannot leave
    it pointing at production. Kills only recorded pids whose cmdline contains the
    staging dir (openfut-utas-host matches BOTH, so pkill-by-pattern is banned).
  * docs/SOLD_STAGING_RUNBOOK.md -- the exact client change and its revert.
  * src/bin/staging_sell.rs -- the synthetic Buyer B, running the REAL settlement
    (CoreEconomy::settle_sale) then mark_sold. Settle-first ordering: a failure
    leaves the listing live with nothing moved. Refuses any path containing
    openfut-promotion or the production ports.
  * scripts/sold-wire-check.py -- proves the whole flow headless before any operator
    time is spent.

WIRE CHECK: 35/35 PASS on the canonical 150-coin sale. Seller 1,000 -> 1,143 (fee 7,
proceeds 143), buyer 20,000 -> 19,850, ownership transferred, exactly ONE
authoritative instance, economy shrank by exactly the fee. Sold row: closed,
currentBid 150, expires 0, twelve atoms, counts sold 1 / selling 0, /trade/status
agreeing. Variant B differs only in bidState and coinsProcessed. Clear: 200 {}, row
gone, counts.sold 0, no coins moved, buyer keeps the item, second clear a safe no-op.

Gates: 104 host lib tests (+9), all 7 host targets green, clippy clean, zero fmt
diffs in the new code. Settlement candidate unchanged. NOT PROMOTED.

Production untouched: prod-host pid 3631953 uptime 2h44m restarts=0, coins and
/tradePile unchanged, nothing under /home/alex/openfut-promotion/state/ opened.

The A/B itself is NOT yet run: it needs a real FIFA client, which is operator work.
This commit is contained in:
funman300
2026-08-18 02:14:18 +00:00
parent 571c5f9261
commit 468bc0fba9
13 changed files with 2880 additions and 27 deletions
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Tear down the isolated FIFA-17 SOLD staging stack brought up by
`scripts/sold-staging-up.py` -- and NOTHING else.
Kill safety is the whole point of this file. `openfut-utas-host` and `openfut-core`
each name TWO live processes on this machine: the staging ones and the PRODUCTION
ones. So there is no pattern matching here at all:
* every pid comes from the manifest the up script wrote;
* before any signal, /proc/<pid>/cmdline is read and MUST contain the staging
directory -- production's cmdline never can, because staging runs binaries
copied into that directory;
* the known production pids are refused explicitly, as a second gate;
* only the process GROUP the up script created (pgid == pid, via
start_new_session) is signalled, so a responder thread/child cannot be orphaned;
* afterwards every staging port is proven free and production is proven alive.
python3 scripts/sold-staging-down.py
python3 scripts/sold-staging-down.py --purge # also delete the staging dir
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import signal
import sys
import time
DEFAULT_STAGING_DIR = "/home/alex/openfut-sold-staging"
FORBIDDEN_PATHS = ("/home/alex/openfut-promotion/state",)
# Production processes that MUST be alive before and after this script runs. These
# two are the ones the batch contract names, and they live in the host pid view.
PROD_PIDS = {3631953: "prod utas-host", 3374264: "prod Core"}
# Reported but not gated: container pids change when the operator restarts the
# container, and a stale entry here would turn a successful teardown into a FATAL.
PROD_PIDS_INFO = {2090886: "prod blaze", 2091170: "prod python oracle",
2090888: "prod pow"}
PROD_PORTS = (8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094)
class Fatal(RuntimeError):
pass
def banner(title: str) -> None:
print()
print("=" * 78)
print(f"== {title}")
print("=" * 78)
def ok(msg: str) -> None:
print(f" [ OK ] {msg}")
def step(msg: str) -> None:
print(f" {msg}")
def safe_path(path: str) -> str:
real = os.path.realpath(path)
for bad in FORBIDDEN_PATHS:
if real == bad or real.startswith(bad + os.sep):
raise Fatal(f"REFUSING to touch production state: {path} -> {real}")
return path
def pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def cmdline_of(pid: int) -> str:
try:
with open(f"/proc/{pid}/cmdline", "rb") as fh:
return fh.read().replace(b"\0", b" ").decode(errors="replace").strip()
except OSError:
return ""
def listening_ports() -> set[int]:
"""Ports in state LISTEN, from the kernel socket table. A trial bind() would
report EADDRINUSE for a stopped server's TIME_WAIT sockets and wrongly claim the
teardown failed."""
ports: set[int] = set()
for path in ("/proc/net/tcp", "/proc/net/tcp6"):
try:
with open(path) as fh:
next(fh, None) # header
for line in fh:
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # TCP_LISTEN
continue
ports.add(int(fields[1].rsplit(":", 1)[1], 16))
except OSError:
continue
return ports
def port_free(port: int) -> bool:
return port not in listening_ports()
def stop_one(rec: dict, staging_dir: str) -> str:
"""Stop exactly one recorded process. Returns a human-readable outcome."""
name, pid = rec["name"], int(rec["pid"])
known_prod = {**PROD_PIDS, **PROD_PIDS_INFO}
if pid in known_prod:
raise Fatal(
f"manifest entry {name} names PRODUCTION pid {pid} ({known_prod[pid]}). "
"REFUSING to signal anything from this manifest."
)
if not pid_alive(pid):
return f"{name} pid {pid}: already gone"
live = cmdline_of(pid)
if staging_dir not in live:
raise Fatal(
f"{name} pid {pid} is alive but its cmdline does NOT contain "
f"{staging_dir!r} -- pid reuse, or the wrong manifest. REFUSING to "
f"signal it.\n cmdline: {live!r}"
)
try:
pgid = os.getpgid(pid)
except OSError:
pgid = pid
recorded_pgid = int(rec.get("pgid", pid))
if pgid != recorded_pgid:
raise Fatal(
f"{name} pid {pid} is in process group {pgid} but the manifest recorded "
f"{recorded_pgid} -- REFUSING to signal a group we did not create."
)
if pgid != pid:
raise Fatal(
f"{name} pid {pid} is not its own group leader (pgid {pgid}) -- the up "
"script always starts a new session, so this is not our process."
)
os.killpg(pgid, signal.SIGTERM)
deadline = time.monotonic() + 15.0
while time.monotonic() < deadline and pid_alive(pid):
time.sleep(0.1)
if pid_alive(pid):
os.killpg(pgid, signal.SIGKILL)
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline and pid_alive(pid):
time.sleep(0.1)
if pid_alive(pid):
raise Fatal(f"{name} pid {pid} survived SIGKILL")
return f"{name} pid {pid} (pgid {pgid}): stopped and verified gone"
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--dir", default=os.environ.get("OPENFUT_SOLD_STAGING_DIR",
DEFAULT_STAGING_DIR),
help=f"staging directory (default: {DEFAULT_STAGING_DIR})")
ap.add_argument("--purge", action="store_true",
help="delete the staging directory after stopping (default: keep "
"the databases and logs as evidence)")
args = ap.parse_args()
staging_dir = safe_path(os.path.abspath(args.dir))
manifest_path = safe_path(os.path.join(staging_dir, "manifest.json"))
try:
banner("STOPPING THE STAGING STACK (recorded pids only)")
step(f"staging dir : {staging_dir}")
if not os.path.exists(manifest_path):
print(f" no manifest at {manifest_path} -- nothing was recorded, so "
"nothing will be signalled.")
print(" If a staging process is somehow still running, find it with "
"its cmdline (it contains the staging dir) and stop it by pid.")
return 0
with open(manifest_path) as fh:
manifest = json.load(fh)
if manifest.get("staging_dir") != staging_dir:
raise Fatal(
f"manifest staging_dir {manifest.get('staging_dir')!r} != "
f"{staging_dir!r} -- REFUSING to act on a foreign manifest."
)
step(f"variant : {manifest.get('variant')}")
for rec in manifest.get("processes", []):
ok(stop_one(rec, staging_dir))
banner("PROVE STAGING IS GONE")
ports = manifest.get("ports", {})
for name, port in sorted(ports.items(), key=lambda kv: kv[1]):
if port in PROD_PORTS:
raise Fatal(f"manifest port {name}={port} is a PRODUCTION port")
if not port_free(port):
raise Fatal(f"staging port {name}={port} is STILL listening")
ok(f"staging port {name} {port} free")
leftovers = []
for rec in manifest.get("processes", []):
pid = int(rec["pid"])
if pid_alive(pid) and staging_dir in cmdline_of(pid):
leftovers.append(f"{rec['name']} pid {pid}")
if leftovers:
raise Fatal("staging processes still alive: " + ", ".join(leftovers))
ok("no recorded staging process is alive")
banner("PROVE PRODUCTION IS STILL UP")
dead = [f"{what} pid {pid}" for pid, what in PROD_PIDS.items()
if not pid_alive(pid)]
for pid, what in PROD_PIDS.items():
if pid_alive(pid):
ok(f"{what} pid {pid} alive")
if dead:
raise Fatal("production process(es) NOT alive: " + ", ".join(dead))
for pid, what in PROD_PIDS_INFO.items():
state = "alive" if pid_alive(pid) else "not found (informational only)"
step(f"{what} pid {pid} {state}")
if args.purge:
shutil.rmtree(safe_path(staging_dir), ignore_errors=True)
ok(f"purged {staging_dir}")
else:
os.replace(manifest_path, safe_path(manifest_path + ".stopped"))
ok(f"kept {staging_dir} (manifest renamed to manifest.json.stopped so a "
"fresh `up` is allowed)")
banner("REMINDER: REVERT THE CLIENT")
print(' On 10.10.0.105, restore "/mnt/games/FIFA 17/openfut.cfg" to:')
print()
print(" host=10.10.0.120")
print(" https_port=8443")
print(" blaze_redirector_port=42127")
print(" blaze_main_port=42130")
print()
print(" then RELAUNCH the FIFA 17 client. See docs/SOLD_STAGING_RUNBOOK.md.")
return 0
except Fatal as exc:
print(f"\nFATAL: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+1028
View File
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
#!/usr/bin/env python3
"""Wire-level verification of the seller-facing SOLD flow, in isolation.
Proves the harness produces a correct, authentic sold row BEFORE any operator time
is spent driving a real FIFA client. Brings up its own Core + utas-host on ephemeral
ports against throwaway databases, runs the real settlement through the
`staging_sell` binary, then reads every seller-facing surface under BOTH A/B
variants and exercises the bulk clear verb.
ISOLATION: production ports 8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216,
8080, 8081 and 8094 are in a hard deny-list checked before every bind and every
request, and nothing under /home/alex/openfut-promotion/state/ is opened.
python3 scripts/sold-wire-check.py [--keep]
"""
import argparse
import http.client
import json
import os
import shutil
import socket
import sqlite3
import subprocess
import sys
import tempfile
import time
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
FORBIDDEN = {8099, 8199, 18080, 8443, 42127, 42130, 42131, 4216, 8080, 8081, 8094}
TS = "2026-01-01T00:00:00Z"
PERSONA = "33068179"
SELLER_CLUB = "club-seller-a"
BUYER_CLUB = "club-buyer-b"
ITEM = "core-disposable-x"
CARD = "def-disposable"
TRADE_ID = "900500150"
GROSS = 150
checks = []
def check(label, ok, detail=""):
checks.append((label, bool(ok), detail))
print(f" [{'PASS' if ok else 'FAIL'}] {label}{(': ' + detail) if detail else ''}")
return ok
def free_port():
for _ in range(200):
s = socket.socket()
s.bind(("127.0.0.1", 0))
p = s.getsockname()[1]
s.close()
if p not in FORBIDDEN and p > 1024:
return p
raise RuntimeError("no free port")
def req(port, method, path, body=None):
assert port not in FORBIDDEN, f"refusing to contact production port {port}"
c = http.client.HTTPConnection("127.0.0.1", port, timeout=20)
headers = {"X-OpenFUT-Game": "fifa17"}
if body is not None:
headers["Content-Type"] = "application/json"
c.request(method, path, body=json.dumps(body) if body is not None else None,
headers=headers)
r = c.getresponse()
raw = r.read()
c.close()
try:
return r.status, json.loads(raw)
except Exception:
return r.status, raw.decode("utf-8", "replace")
def wait_http(port, path, timeout=45, proc=None, log=None):
deadline = time.time() + timeout
while time.time() < deadline:
if proc is not None and proc.poll() is not None:
tail = ""
if log and os.path.exists(log):
tail = open(log).read()[-1500:]
raise RuntimeError(f"process exited {proc.returncode}\n{tail}")
try:
st, _ = req(port, "GET", path)
if st < 500:
return
except Exception:
time.sleep(0.25)
tail = open(log).read()[-1500:] if log and os.path.exists(log) else ""
raise RuntimeError(f"{path} on {port} never became ready\n{tail}")
def seed(db):
"""Two identities by direct SQL: Seller A (the FIFA persona) and synthetic Buyer B."""
con = sqlite3.connect(db)
for prof, club, coins, game in (
("prof-seller-a", SELLER_CLUB, 1_000, "fifa17"),
("prof-buyer-b", BUYER_CLUB, 20_000, "buyer-game"),
):
con.execute(
"INSERT INTO profiles (id, username, game_id, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?)", (prof, prof, game, TS, TS))
con.execute(
"INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)", (club, prof, club, coins, TS, TS))
con.execute(
"INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) "
"VALUES (?, ?, ?, 0, ?)", (ITEM, SELLER_CLUB, CARD, TS))
con.commit()
con.close()
def owner_of(db, item):
con = sqlite3.connect(db)
row = con.execute("SELECT club_id FROM owned_cards WHERE id = ?", (item,)).fetchone()
n = con.execute("SELECT COUNT(*) FROM owned_cards WHERE id = ?", (item,)).fetchone()[0]
coins = dict(con.execute("SELECT id, coins FROM clubs").fetchall())
con.close()
return (row[0] if row else None), n, coins
def start_core(tmp, port, log):
db = os.path.join(tmp, "core.db")
pack = os.path.join(tmp, "pack.json")
with open(pack, "w") as f:
# A top-level ARRAY: Core's content-pack loader expects a sequence, not a
# map. Needed because the preflight refuses to start when an owned card
# references a CardDefinitionId no pack defines.
json.dump([{
"id": CARD, "name": "Disposable", "overall": 75, "position": "ST",
"nation": "Nation", "league": "League", "club": "Club",
"pace": 75, "shooting": 75, "passing": 75, "dribbling": 75,
"defending": 40, "physical": 70, "rarity": "gold",
"image_path": None,
}], f)
env = dict(os.environ,
LISTEN_ADDR=f"127.0.0.1:{port}",
DATABASE_URL=f"sqlite://{db}",
# Core's real data dir (read-only): it needs chemistry_styles.json
# and friends. The throwaway DB and the content pack stay in tmp.
DATA_DIR=os.path.join(REPO, "openfut-core", "data"),
OPENFUT_CONTENT_PACKS=pack)
# Migrate-only pass first: Core owns its schema, so the fixture cannot be
# seeded into an empty file. Stop it before the external writer touches the db.
with open(log, "w") as lf:
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
wait_http(port, "/health", proc=p, log=log)
p.terminate()
p.wait(timeout=20)
seed(db)
with open(log, "a") as lf:
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-core")],
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
wait_http(port, "/health", proc=p, log=log)
return p, db
def start_host(tmp, port, core_port, log, variant, coins_processed="0",
count_mode="active"):
with open(os.path.join(tmp, "catalog.json"), "w") as f:
# Minimal STAGING catalog. Deliberately NOT the production catalog, which
# lives under /home/alex/openfut-promotion/state/ and must never be opened.
json.dump({"schema_version": 1, "game": "fifa17",
"cards": {CARD: {"asset_id": 212188, "version": 0,
"rareflag": 1, "kind": "player"}}}, f)
env = dict(os.environ,
OPENFUT_UTAS_HOST_ADDR=f"127.0.0.1:{port}",
OPENFUT_CORE_URL=f"http://127.0.0.1:{core_port}",
# Deliberately dead: any Python fallback must fail closed and be
# visible, never silently serve production data.
OPENFUT_UTAS_PYTHON_URL="http://127.0.0.1:9",
OPENFUT_FIFA17_TABLES_DIR=os.path.join(REPO, "fifa17-recon/data/tables"),
OPENFUT_IDENTITY_STORE=os.path.join(tmp, "identity.json"),
OPENFUT_PERSONA_ID=PERSONA,
OPENFUT_MARKET_DB=os.path.join(tmp, "market.db"),
OPENFUT_PILE_DB=os.path.join(tmp, "pile.db"),
OPENFUT_FIFA17_SOLD_EXPERIMENT=variant,
OPENFUT_FIFA17_SOLD_COINS_PROCESSED=coins_processed,
OPENFUT_FIFA17_SOLD_COUNT_MODE=count_mode,
OPENFUT_FIFA17_CATALOG=os.path.join(tmp, "catalog.json"),
RUST_LOG="info")
with open(log, "w") as lf:
p = subprocess.Popen([os.path.join(REPO, "target/release/openfut-utas-host")],
cwd=tmp, env=env, stdout=lf, stderr=subprocess.STDOUT)
wait_http(port, "/ut/game/fifa17/tradePile/counts", proc=p, log=log)
return p
def banner(t):
print("\n" + "=" * 72)
print(f"== {t}")
print("=" * 72)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--keep", action="store_true")
args = ap.parse_args()
tmp = tempfile.mkdtemp(prefix="openfut-sold-wire-")
procs = []
try:
core_port, host_port = free_port(), free_port()
core_log = os.path.join(tmp, "core.log")
host_log = os.path.join(tmp, "host.log")
banner("ISOLATED STAGING (production untouched)")
print(f" tmp : {tmp}")
print(f" core : 127.0.0.1:{core_port}")
print(f" utas-host : 127.0.0.1:{host_port}")
print(f" forbidden : {sorted(FORBIDDEN)}")
core, core_db = start_core(tmp, core_port, core_log)
procs.append(core)
host = start_host(tmp, host_port, core_port, host_log, "highest")
procs.append(host)
print(" both ready")
bann = [l for l in open(host_log) if "sold-experiment" in l]
check("host banner names the variant", any("bidState=highest" in l for l in bann),
(bann[0].strip() if bann else "no banner"))
banner("BEFORE — seller A owns the item, nothing listed")
own, n, coins = owner_of(core_db, ITEM)
print(f" owner={own} instances={n} coins={coins}")
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
print(f" /tradePile total={pile.get('total')} counts={json.dumps(counts)}")
check("seller owns the item", own == SELLER_CLUB, str(own))
check("no rows before listing", pile.get("total") == 0)
check("sold counter starts at 0", counts.get("sold") == 0)
banner(f"LIST — authentic active listing at {GROSS} coins")
# Seed the listing directly into the staging market db: the client normally
# does this via POST /auctionhouse, which needs a wire-id mapping we do not
# have in this headless check. The LISTING SHAPE is identical either way.
mdb = os.path.join(tmp, "market.db")
con = sqlite3.connect(mdb)
con.execute(
"INSERT INTO listings (listing_id, card_id, core_item_id, wire_item_id, "
"wire_resource_id, start_price, buy_now_price, owner, state, created_at, "
"item_json, duration_secs) VALUES (?,?,?,?,?,?,?,?, 'active', ?, ?, ?)",
(TRADE_ID, CARD, ITEM, 100000178, 212188, GROSS, GROSS, "CAGE",
str(int(time.time() * 1000)), json.dumps({
"id": 100000178, "resourceId": 212188, "rating": 75,
"preferredPosition": "ST", "itemState": "forSale",
"untradeable": False, "assetId": 212188}), 3600))
con.commit()
con.close()
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
row = pile["auctionInfo"][0]
print(f" active row: tradeState={row['tradeState']} bidState={row['bidState']} "
f"expires={row['expires']} counts={json.dumps(counts)}")
check("active row is active/none", row["tradeState"] == "active" and row["bidState"] == "none")
check("counts.selling == 1 while active", counts.get("selling") == 1)
check("counts.sold still 0 while active", counts.get("sold") == 0)
banner("PURCHASE — synthetic Buyer B, through the REAL settlement path")
out = subprocess.run(
[os.path.join(REPO, "target/release/staging_sell"),
"--market-db", mdb, "--core-url", f"http://127.0.0.1:{core_port}",
"--trade-id", TRADE_ID, "--item", ITEM,
"--seller", SELLER_CLUB, "--buyer", BUYER_CLUB, "--gross", str(GROSS)],
capture_output=True, text=True, timeout=120)
print(" " + "\n ".join((out.stdout + out.stderr).strip().splitlines()))
check("staging_sell succeeded", out.returncode == 0, f"exit {out.returncode}")
own, n, coins = owner_of(core_db, ITEM)
fee = GROSS * 5 // 100
print(f" owner={own} instances={n} coins={coins} fee={fee}")
check("ownership transferred to buyer", own == BUYER_CLUB, str(own))
check("exactly ONE authoritative instance", n == 1, str(n))
check("buyer debited gross", coins.get(BUYER_CLUB) == 20_000 - GROSS,
str(coins.get(BUYER_CLUB)))
check("seller credited net", coins.get(SELLER_CLUB) == 1_000 + GROSS - fee,
str(coins.get(SELLER_CLUB)))
check("economy shrank by exactly the fee",
21_000 - sum(coins.values()) == fee, str(21_000 - sum(coins.values())))
banner("SOLD ROW — variant A: closed / highest")
st, pile = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, counts = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
st3, status = req(host_port, "GET", f"/ut/game/fifa17/trade/status?tradeIds={TRADE_ID}")
a_row = pile["auctionInfo"][0] if pile.get("auctionInfo") else {}
print(" " + json.dumps(a_row, indent=2).replace("\n", "\n "))
print(f" counts={json.dumps(counts)}")
check("sold row is present in the pile", pile.get("total") == 1)
check("tradeState closed", a_row.get("tradeState") == "closed")
check("bidState highest (variant A)", a_row.get("bidState") == "highest")
check("currentBid == sale price", a_row.get("currentBid") == GROSS)
check("expires 0", a_row.get("expires") == 0)
check("twelve atoms exactly", len(a_row) == 12, str(len(a_row)))
check("counts.sold == 1", counts.get("sold") == 1)
check("counts.selling == 0", counts.get("selling") == 0)
check("/trade/status agrees", status["auctionInfo"][0]["tradeState"] == "closed"
and status["auctionInfo"][0]["bidState"] == "highest")
banner("VARIANT B — same state, restart host with closed / buyNow")
host.terminate(); host.wait(timeout=20); procs.remove(host)
host = start_host(tmp, host_port, core_port, host_log, "buyNow",
coins_processed="1", count_mode="active_plus_sold")
procs.append(host)
st, pileb = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st2, countsb = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
b_row = pileb["auctionInfo"][0]
print(f" bidState={b_row['bidState']} coinsProcessed={b_row['coinsProcessed']} "
f"counts={json.dumps(countsb)}")
check("bidState buyNow (variant B)", b_row.get("bidState") == "buyNow")
check("coinsProcessed 1 when asked", b_row.get("coinsProcessed") == 1)
check("count_mode active_plus_sold counts the sold row",
countsb.get("count") == 1 and countsb.get("sold") == 1,
json.dumps(countsb))
differing = sorted(k for k in a_row if a_row.get(k) != b_row.get(k))
check("A/B differ ONLY in bidState and coinsProcessed",
differing == ["bidState", "coinsProcessed"], str(differing))
banner("CLEAR — the PE-proven bulk verb DELETE .../trade/sold")
pre_coins = owner_of(core_db, ITEM)[2]
st, body = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
print(f" HTTP {st} body={json.dumps(body)}")
st2, pilec = req(host_port, "GET", "/ut/game/fifa17/tradePile")
st3, countsc = req(host_port, "GET", "/ut/game/fifa17/tradePile/counts")
own2, n2, post_coins = owner_of(core_db, ITEM)
print(f" after clear: total={pilec.get('total')} counts={json.dumps(countsc)} "
f"owner={own2} instances={n2}")
check("clear acks 200 {}", st == 200 and body == {})
check("sold row gone from the pile", pilec.get("total") == 0)
check("counts.sold back to 0", countsc.get("sold") == 0)
check("clear moved NO coins", pre_coins == post_coins, f"{pre_coins} -> {post_coins}")
check("buyer still owns the item after clear", own2 == BUYER_CLUB, str(own2))
check("still exactly one instance", n2 == 1, str(n2))
st, again = req(host_port, "DELETE", "/ut/delete/game/fifa17/trade/sold")
check("clearing again is a safe no-op", st == 200)
cleared = [l for l in open(host_log) if "market-clear-sold" in l]
check("clear is logged for capture", bool(cleared),
cleared[-1].strip() if cleared else "no log line")
banner("PRODUCTION UNTOUCHED")
alive = subprocess.run(["ps", "-o", "pid=", "-p", "3631953"],
capture_output=True, text=True).stdout.strip()
check("prod-host pid 3631953 still alive", alive == "3631953", alive or "gone")
opened = subprocess.run(
["bash", "-lc",
"ls -l /proc/*/fd 2>/dev/null | grep -c openfut-promotion || true"],
capture_output=True, text=True).stdout.strip()
print(f" staging fds referencing production state: (informational) {opened}")
banner("RESULT")
passed = sum(1 for _, ok, _ in checks if ok)
print(f" {passed}/{len(checks)} checks passed")
failed = [l for l, ok, _ in checks if not ok]
if failed:
print(" FAILED: " + "; ".join(failed))
print("\n " + ("ALL CHECKS PASSED" if not failed else "FAILURES PRESENT"))
return 0 if not failed else 1
finally:
for p in procs:
try:
p.terminate(); p.wait(timeout=15)
except Exception:
try:
p.kill()
except Exception:
pass
if args.keep:
print(f"\n kept {tmp}")
else:
shutil.rmtree(tmp, ignore_errors=True)
print(f"\n removed {tmp}")
if __name__ == "__main__":
sys.exit(main())