Files
OpenFUT/scripts/fifa17-apply-smoke.py
T
funman300 6c97bc4e2b 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).
2026-08-22 18:23:22 +00:00

177 lines
7.1 KiB
Python
Executable File

#!/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)