#!/usr/bin/env python3 """Smoke-test the REAL consumable-apply route against the RUNNING staging host. `POST ut//item/resource/` is now Rust-owned and it MUTATES: Core destroys the source consumable instance and raises the target's match-contracts in ONE transaction. There is no `OPENFUT_FIFA17_APPLY_PROBE` gate any more and no staging-only diagnostic -- the route is unconditional -- so what needs proving changed. This checks the two halves that matter: * every REFUSAL leaves Core byte-identical (fail closed; nothing half-applied); * the one accepted apply consumes exactly ONE copy, and the contract number the host logged is the number the client can actually read back off the wire. It deliberately does NOT re-derive the FIFA 17 grant matrix. Duplicating those 13 rows here would create a second source of truth that could silently disagree with `openfut-adapter-fifa17::fut::contract_cards`, which is the authority. Instead the host's own `granted/before/after` are checked for internal consistency (`after == min(99, before + granted)`) and against the projected wire state. Replaying the accepted request is NOT idempotent and is not attempted: a successful apply DESTROYS the source instance, so a second POST legitimately consumes the NEXT owned copy. Idempotency is Core's, keyed on the source instance id (`fifa17:apply:->`), and a transport retry of one logical action replays that key rather than this HTTP request. """ import json import re import subprocess import urllib.error import urllib.request BASE = "http://127.0.0.1:8299" SKU = "fifa17" LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log" SNAPSHOT = "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py" # A PLAYER contract card (cardsubtypeid 201) owned on staging: the one accepted # apply. It must match the snapshot script's SOURCE_RES so the copy count below # is the count of THIS stack. CONTRACT_RES = 5001004 # Position modifier: a real owned family whose apply effect is NOT proven. POSITION_RES = 5003068 # Manager contract (cardsubtypeid 202): unservable until staff ratings are # imported, because the grant is keyed on the TARGET's rating tier. MANAGER_RES = 5001010 UNOWNED_RES = 1234567 TARGET_WIRE = 100000003 UNKNOWN_WIRE = 999999999 H = {"X-OpenFUT-Game": SKU, "Content-Type": "application/json"} results = [] def post(resource_id, wires): body = json.dumps({"apply": [{"id": w} for w in wires]}).encode() req = urllib.request.Request( "%s/ut/game/%s/item/resource/%d" % (BASE, SKU, resource_id), data=body, headers=H, method="POST") try: with urllib.request.urlopen(req, timeout=30) as r: return r.status, r.read().decode() except urllib.error.HTTPError as e: return e.code, e.read().decode() def snapshot(): out = subprocess.run(["python3", SNAPSHOT], capture_output=True, text=True) if out.returncode != 0: raise SystemExit("snapshot failed: %s" % out.stderr.strip()) return json.loads(out.stdout) def apply_log_since(mark): with open(LOG) as f: return [ln.rstrip() for ln in list(f)[mark:] if "consumable-apply" in ln] def check(name, ok, detail=""): results.append(ok) print(" [%s] %s%s" % ("OK" if ok else "FAIL", name, (" -- " + detail) if detail else "")) def skip(name, detail): print(" [SKIP] %s -- %s" % (name, detail)) def stack_count(snap): """Owned copies of CONTRACT_RES, or 0 once the last one is consumed (the stack disappears from the consumables screen entirely).""" return ((snap.get("source_stack") or {}).get("count")) or 0 with open(LOG) as f: mark = sum(1 for _ in f) base = snapshot() print("=== every refusal must leave Core byte-identical ===") REFUSALS = [ ("batch semantics unproven", CONTRACT_RES, [TARGET_WIRE, TARGET_WIRE + 1], 400, "apply_batch_unsupported"), ("unknown target wire id", CONTRACT_RES, [UNKNOWN_WIRE], 404, "not_owned"), ("source consumable not owned", UNOWNED_RES, [TARGET_WIRE], 404, "not_owned"), ("non-contract family fails closed", POSITION_RES, [TARGET_WIRE], 409, "apply_effect_unproven"), ("manager contract refused: staff ratings not imported", MANAGER_RES, [TARGET_WIRE], 409, "manager_contract_unsupported"), ] for name, res, wires, want_status, want_code in REFUSALS: status, body = post(res, wires) got = "status=%s body=%s" % (status, body[:80]) # A family-gate case can only be exercised if this profile owns such a card; # source resolution runs first, so an unowned one answers 404 not_owned. Say # so rather than scoring a pass or a failure that means nothing. if want_status != 404 and status == 404 and "not_owned" in body: skip(name, "profile owns no resource %d" % res) continue check(name, status == want_status and want_code in body, got) check("Core unchanged by every refusal", snapshot() == base) print("\n=== the one accepted apply must mutate, exactly once ===") status, body = post(CONTRACT_RES, [TARGET_WIRE]) parsed = None try: parsed = json.loads(body) except ValueError: pass check("client-shaped ack", status == 200 and parsed == {"itemData": []}, "status=%s body=%s" % (status, body[:80])) after = snapshot() lines = apply_log_since(mark) granted_lines = [ln for ln in lines if "granted=" in ln] fields = {} if granted_lines: fields = dict(re.findall(r"(subtype|granted|before|after|applied)=(-?\w+)", granted_lines[-1])) check("host logged the grant it applied", {"subtype", "granted", "before", "after"} <= set(fields), granted_lines[-1] if granted_lines else "no consumable-apply grant line") if {"subtype", "granted", "before", "after"} <= set(fields): granted = int(fields["granted"]) before_n = int(fields["before"]) after_n = int(fields["after"]) check("player contract subtype", fields["subtype"] == "201", "subtype=%s" % fields["subtype"]) check("Core applied a real grant", granted > 0, "granted=%d" % granted) check("cap respected: after == min(99, before + granted)", after_n == min(99, before_n + granted), "before=%d granted=%d after=%d" % (before_n, granted, after_n)) check("not a replay", fields.get("applied") == "true", "applied=%s" % fields.get("applied")) wire_contract = (after.get("target") or {}).get("contract") check("the wire shows what Core recorded", wire_contract == after_n, "wire=%s core=%s" % (wire_contract, after_n)) check("exactly one source copy consumed", stack_count(after) == stack_count(base) - 1, "before=%s after=%s" % (stack_count(base), stack_count(after))) check("exactly one owned row destroyed", after["owned_rows"] == base["owned_rows"] - 1, "before=%s after=%s" % (base["owned_rows"], after["owned_rows"])) check("coins untouched: an apply is not a sale", after["coins"] == base["coins"], "before=%s after=%s" % (base["coins"], after["coins"])) print("\n=== host log ===") for line in lines: print(" " + line[:200]) ok = all(results) print("\nRESULT: %s" % ("OK" if ok else "FAILED")) raise SystemExit(0 if ok else 1)