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:
Executable
+176
@@ -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)
|
||||
Reference in New Issue
Block a user