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