6c97bc4e2b
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).
177 lines
7.1 KiB
Python
Executable File
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)
|