feat(fifa17): real player-contract consumable apply, replacing the probe

POST /ut/game/fifa17/item/resource/<rid> {"apply":[{"id":N}]} now performs a
durable atomic contract application instead of falling through to Python.

THE RULE. grant = fcc_contractcards[card][tier(TARGET.rating)], then
min(99, contract + grant). The column is keyed on the TARGET's tier, NOT the
card's own -- all 36 cells of EA's shipped table match the published FIFA 17
matrix, and staging discriminates the two readings outright: a bronze-RARE
card on a rating-89 player granted 3 (the gold column), where the card-level
reading predicts 15.

No client binary reads fcc_contractcards -- a string scan of every .exe/.dll
in the install finds it referenced nowhere, and CardsDLL reads only 14 fcc_
tables (fcc_discardcoins among them, which is why quick-sell prices locally).
Consumable effects are server-authoritative, so EA's shipped table is the only
non-invented source and the client renders whatever we persist and re-serve.

The host computes the grant, Core owns the mutation -- the same split
quick-sell already uses (host prices via discard_value, Core performs
sell_item), and what migration 0027 means by "Core defines NO per-category
formula".

FAILS CLOSED, never 200-and-do-nothing: manager contracts 409 because staff
ratings are unimported so the target tier is unknowable; every other family
409 as unproven; batch 400; unresolvable operand 404. Core's deterministic
refusals pass through with their own status instead of collapsing to 503,
which would tell the client to retry a request that can never succeed.

`contract: 7` stops being a hardcode in shape_item/shape_staff_item and
becomes the fallback for an instance Core tracks no contract for. `fitness: 99`
is the same class of hardcode and is deliberately untouched.

CLEAN CUTOVER: Route::ConsumableApplyProbe, its handler, apply_probe_enabled,
the OPENFUT_FIFA17_APPLY_PROBE gate and both probe scripts are deleted. A
handler no classifier can reach is this repo's recurring defect class, and the
new economy arm preempts the probe. fifa17-migration-rehearse.py also drove
the probe (spelled "apply probe", so an apply-probe grep missed it) and would
have eaten a card off the rehearsal profile; retargeted to a non-mutating
assertion.

Not implemented, on purpose: the stored-manager bonus (real mechanic, rule
appears in no shipped table -- guessing it would corrupt the proven part) and
contract decrement per match (nothing spends contracts yet).
This commit is contained in:
funman300
2026-08-22 18:23:22 +00:00
parent 3c67fea074
commit 6c97bc4e2b
19 changed files with 1348 additions and 270 deletions
-41
View File
@@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""With OPENFUT_FIFA17_APPLY_PROBE unset the apply MUST fall through to the
Python passthrough -- i.e. exactly the behaviour that existed before the probe.
That is the production-safety claim, so prove it rather than assert it."""
import subprocess
import urllib.error
import urllib.request
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
mark = sum(1 for _ in open(LOG))
before = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
req = urllib.request.Request(
"http://127.0.0.1:8299/ut/game/fifa17/item/resource/5001004",
data=b'{"apply":[{"id":100000003}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
st, body = r.status, r.read().decode()
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print("--- log with the gate OFF ---")
lines = list(open(LOG))[mark:]
for line in lines:
if any(k in line for k in ("apply-probe", "passthrough", "PYTHON")):
print(" " + line.rstrip()[:150])
probe_served = any("apply-probe" in line for line in lines)
went_python = any("owner=PYTHON" in line for line in lines)
after = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
print("\n probe served (must be False): %s" % probe_served)
print(" proxied to Python (must be True): %s" % went_python)
print(" Core unchanged: %s" % (before == after))
print("\nRESULT: %s" % ("OK -- gate fails closed"
if (not probe_served and went_python and before == after)
else "FAILED"))
-66
View File
@@ -1,66 +0,0 @@
#!/usr/bin/env python3
"""Smoke-test the consumable-apply probe against the RUNNING staging host.
Replays the exact request the client sent, plus the batch and unknown-target
edges, then proves Core is byte-identical afterwards. No client needed.
"""
import json
import subprocess
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8299"
PATH = "/ut/game/fifa17/item/resource/5001004"
H = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
def post(path, payload):
req = urllib.request.Request(
BASE + path, data=payload, 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()
before = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
mark = sum(1 for _ in open(LOG))
print("=== 1. the EXACT request captured from the client ===")
st, body = post(PATH, b'{"apply":[{"id":100000003}]}')
print(" status=%s body=%s" % (st, body))
ok1 = st == 200 and json.loads(body) == {"itemData": []}
print("\n=== 2. batch: semantics unproven, must be REFUSED not guessed ===")
st2, body2 = post(PATH, b'{"apply":[{"id":100000003},{"id":100000004}]}')
print(" status=%s body=%s" % (st2, body2[:90]))
ok2 = st2 == 400 and "apply_batch_unsupported" in body2
print("\n=== 3. unknown target: observed, never invented ===")
st3, body3 = post(PATH, b'{"apply":[{"id":999999999}]}')
print(" status=%s body=%s" % (st3, body3))
ok3 = st3 == 200
print("\n=== 4. unowned source resource ===")
st4, body4 = post("/ut/game/fifa17/item/resource/1234567", b'{"apply":[{"id":100000003}]}')
print(" status=%s body=%s" % (st4, body4))
ok4 = st4 == 200
print("\n=== host log ===")
for line in list(open(LOG))[mark:]:
if "apply-probe" in line:
print(" " + line.rstrip()[:190])
after = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
same = before == after
print("\n=== 5. Core unchanged by all four requests: %s ===" % ("YES" if same else "NO"))
if not same:
for b, a in zip(before.splitlines(), after.splitlines()):
if b != a:
print(" BEFORE %s\n AFTER %s" % (b.strip(), a.strip()))
print("\nRESULT: %s" % ("OK" if all([ok1, ok2, ok3, ok4, same]) else "FAILED"))
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Smoke-test the REAL consumable-apply route against the RUNNING staging host.
`POST ut/<sku>/item/resource/<resourceId>` 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:<source>-><target>`), 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)
+10 -3
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env python3
"""Core snapshot around the consumable-apply probe: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after; the
probe must change NOTHING."""
"""Core snapshot around a consumable APPLY: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after.
A player-contract apply must move EXACTLY three things: the source consumable
loses one copy, the target's `contract` rises to min(99, before + grant), and
nothing else — coins in particular must not move, because an apply is not an
economy credit. Everything else in this snapshot is here to prove it stayed put.
(Superseded for acceptance by fifa17-contract-apply-validate.py, which asserts
the deltas itself; this remains the raw before/after dump for eyeballing.)"""
import json
import sys
import urllib.request
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""Acceptance harness for FIFA17 player-contract consumable APPLY (staging).
Exercises the real route end to end and asserts the full observable contract:
* the target's `contract` goes from B to min(99, B + grant), where `grant` is
read from the shipped EA table `fcc_contractcards` INDEPENDENTLY of the Rust
implementation (this is a cross-check, not a mirror);
* exactly one source copy is consumed;
* coins do NOT move (an apply is not an economy credit);
* replaying the exhausted resource fails closed rather than granting again;
* a manager contract and a non-contract consumable both fail closed.
Read-only against production by construction: every URL is the staging port.
"""
import argparse
import json
import sqlite3
import sys
import urllib.error
import urllib.request
HOST = "http://127.0.0.1:8299"
CORE_DB = "/home/alex/openfut-sold-staging/staging-core.db"
HDRS = {"X-OpenFUT-Game": "fifa17"}
TABLE = "/home/alex/OpenFUT/fifa17-recon/data/tables/fcc_contractcards.json"
PLAYER_CONTRACT_SUBTYPE = 201
MANAGER_CONTRACT_SUBTYPE = 202
CAP = 99
FAILURES = []
def check(label, got, want):
ok = got == want
print(f" [{'OK ' if ok else 'FAIL'}] {label}: got {got!r} want {want!r}")
if not ok:
FAILURES.append(label)
return ok
def req(method, path, body=None):
data = json.dumps(body).encode() if body is not None else None
r = urllib.request.Request(HOST + path, data=data, headers=HDRS, method=method)
if data:
r.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(r, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw.decode(errors="replace")
def grant_from_table(resource_id, target_rating):
"""The authoritative grant, read straight from EA's shipped table.
Column is selected by the TARGET's tier (bronze <65, silver 65..74, gold
>=75) — NOT by the card's own tier. Verified 36/36 against the published
FIFA 17 matrix.
"""
d = json.load(open(TABLE))
rows = d if isinstance(d, list) else (d.get("rows") or list(d.values())[0])
row = next((r for r in rows if r["carddbid"] == resource_id), None)
if row is None:
return None
col = "bronze" if target_rating < 65 else ("silver" if target_rating < 75 else "gold")
return row[col]
def core_snapshot():
con = sqlite3.connect(f"file:{CORE_DB}?mode=ro", uri=True)
try:
snap = {
"coins": con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0],
"owned": con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0],
"by_kind": dict(
con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1")
),
}
cols = [r[1] for r in con.execute("PRAGMA table_info(owned_cards)")]
snap["has_contract_column"] = "contract_matches" in cols
if snap["has_contract_column"]:
snap["contracts_set"] = con.execute(
"SELECT COUNT(*) FROM owned_cards WHERE contract_matches IS NOT NULL"
).fetchone()[0]
return snap
finally:
con.close()
def club_players():
"""Every owned player on the wire, with its contract, keyed by wire id."""
out = {}
for pile in ("/ut/game/fifa17/club?type=player&start=0&count=200",):
_, body = req("GET", pile)
for it in (body or {}).get("itemData") or []:
if it.get("itemType") == "player":
out[it["id"]] = it
return out
def consumable_stacks():
_, body = req("GET", "/ut/game/fifa17/club/consumables/development")
return {s["resourceId"]: s for s in (body or {}).get("itemData") or []}
def pick_source(stacks, subtype_range):
for rid, s in sorted(stacks.items()):
if rid in subtype_range:
return rid, s
return None, None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--source", type=int, default=None,
help="contract resource id to apply (default: first owned player contract)")
ap.add_argument("--target", type=int, default=None,
help="target player wire id (default: lowest-rated owned player)")
args = ap.parse_args()
print("== BEFORE ==")
before = core_snapshot()
print(json.dumps(before, indent=2, sort_keys=True))
if not before["has_contract_column"]:
print("FATAL: migration 0028 not applied — owned_cards has no contract_matches column")
return 2
stacks = consumable_stacks()
players = club_players()
if not players:
print("FATAL: no owned players on the wire")
return 2
player_contracts = {r: s for r, s in stacks.items() if 5001001 <= r <= 5001006 or r == 5001013}
src = args.source or next(iter(sorted(player_contracts)), None)
if src is None:
print("FATAL: no owned PLAYER contract consumable to apply")
return 2
# Lowest-rated target maximises the observable delta (a bronze target draws
# the largest column) and exercises the tier selector rather than assuming gold.
tgt_id = args.target or min(players, key=lambda i: players[i].get("rating", 0))
tgt = players[tgt_id]
rating = tgt["rating"]
grant = grant_from_table(src, rating)
c_before = tgt.get("contract")
expect_after = min(CAP, c_before + grant)
print(f"\n== APPLY ==\n source resource {src} (stack count {stacks[src].get('count')})")
print(f" target wire {tgt_id} rating {rating} -> tier "
f"{'bronze' if rating < 65 else 'silver' if rating < 75 else 'gold'}")
print(f" table grant {grant}; contract {c_before} -> expect {expect_after}")
status, body = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}]})
print(f" HTTP {status} {json.dumps(body)[:200] if body is not None else ''}")
check("apply status", status, 200)
check("apply body", body, {"itemData": []})
print("\n== AFTER ==")
after = core_snapshot()
players2 = club_players()
stacks2 = consumable_stacks()
print(json.dumps(after, indent=2, sort_keys=True))
check("coins unchanged", after["coins"], before["coins"])
check("one owned row consumed", after["owned"], before["owned"] - 1)
check("one consumable consumed",
after["by_kind"].get("consumable", 0), before["by_kind"].get("consumable", 0) - 1)
check("target contract granted", players2.get(tgt_id, {}).get("contract"), expect_after)
check("source stack decremented",
(stacks2.get(src) or {}).get("count", 0), (stacks[src].get("count") or 1) - 1)
# Untouched players must not have drifted.
drifted = [i for i, p in players2.items()
if i != tgt_id and p.get("contract") != players.get(i, {}).get("contract")]
check("no collateral contract changes", drifted, [])
print("\n== FAIL-CLOSED CASES ==")
exhausted = (stacks2.get(src) or {}).get("count", 0) == 0
if exhausted:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}", {"apply": [{"id": tgt_id}]})
check("replay of exhausted resource refused", s, 404)
mgr = next((r for r in stacks2 if 5001007 <= r <= 5001012), None)
if mgr:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{mgr}", {"apply": [{"id": tgt_id}]})
check("manager contract fails closed (staff ratings unimported)", s, 409)
other = next((r for r in stacks2 if not (5001001 <= r <= 5001013)), None)
if other:
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{other}", {"apply": [{"id": tgt_id}]})
check("unproven family fails closed", s, 409)
s, _ = req("POST", f"/ut/game/fifa17/item/resource/{src}",
{"apply": [{"id": tgt_id}, {"id": tgt_id}]})
check("batch apply refused", s in (400, 404), True)
print("\n== RESULT ==")
if FAILURES:
print("FAILED: " + ", ".join(FAILURES))
return 1
print(f"PASS — contract {c_before} -> {expect_after} on wire {tgt_id}, "
f"one copy of {src} consumed, coins flat")
return 0
if __name__ == "__main__":
sys.exit(main())
+10 -4
View File
@@ -77,10 +77,15 @@ try:
print(json.dumps(checks, indent=2, sort_keys=True))
print("\n=== apply probe MUST be off in the candidate ===")
# The consumable apply is Rust-owned and MUTATES, so the rehearsal must not
# send one that would succeed: a two-target body is refused (400
# apply_batch_unsupported) before anything is resolved or written. That
# refusal is only reachable if the candidate host CLAIMS the route -- a 502
# means it fell through to the (dead) upstream, i.e. the cutover is missing.
print("\n=== the candidate must OWN the consumable apply ===")
req = urllib.request.Request(
"http://127.0.0.1:%d/ut/game/fifa17/item/resource/5001004" % HOST_PORT,
data=b'{"apply":[{"id":100000003}]}',
data=b'{"apply":[{"id":100000003},{"id":100000004}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
@@ -89,8 +94,9 @@ try:
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print(" probe OFF (must be 502 upstream-unavailable): %s"
% (st == 502 and "upstream" in body))
print(" apply route Rust-owned, nothing mutated (must be 400 "
"apply_batch_unsupported): %s"
% (st == 400 and "apply_batch_unsupported" in body))
finally:
for p in procs:
try: