fifa17-recon: transfer market buy/bid flow (stateful, tested)
trade_route now resolves the auction from its tradeId and, on buy-now (bid >= buyNowPrice), spends coins + grants the won card to the club + echoes the CLOSED auction (FutISOfferTrade shape). Reuses the validated auction record (0x18013e410) so it stays freeze-safe; whether FIFA surfaces the won item post-buy is functional (needs live test). Insufficient funds -> 461. tools/test_market_buy.py: offline unit test on a TEMP profile (never touches the real save) -- verifies coin deduction, card grant, closed-auction shape, and the 461 path. PASS. Read-only contract suite still 311/311. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Offline unit test for the transfer-market BUY flow (tools/utas_server.trade_route).
|
||||
|
||||
Runs entirely against a TEMP profile (FUT_PROFILE) so it never mutates the real
|
||||
save. Verifies: buy-now deducts coins + grants the won card to the club + echoes
|
||||
a CLOSED auction with the validated record shape; insufficient funds -> 461.
|
||||
No server / no live FIFA needed. Run: python3 tools/test_market_buy.py
|
||||
"""
|
||||
import os, json, sys, tempfile
|
||||
|
||||
os.environ["FUT_PROFILE"] = os.path.join(tempfile.gettempdir(), "fut_buy_test_profile.json")
|
||||
if os.path.exists(os.environ["FUT_PROFILE"]):
|
||||
os.remove(os.environ["FUT_PROFILE"])
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import utas_server as u # noqa: E402
|
||||
|
||||
|
||||
class FakeH:
|
||||
def __init__(self, cmd, path, body=b""):
|
||||
self.command, self.path, self._body = cmd, path, body
|
||||
|
||||
|
||||
def main():
|
||||
fails = []
|
||||
|
||||
def ok(name, cond, detail=""):
|
||||
if not cond:
|
||||
fails.append(f"{name}: {detail}")
|
||||
|
||||
# a listing to buy (cheapest affordable)
|
||||
_, mkt = u.auctionhouse_route(FakeH("GET", "/ut/game/fifa17/auctionhouse?type=player"))
|
||||
recs = sorted(mkt["auctionInfo"], key=lambda r: r["buyNowPrice"])
|
||||
rec0 = recs[0]
|
||||
tid, price = rec0["tradeId"], rec0["buyNowPrice"]
|
||||
coins0, items0 = u.STORE.coins(), len(u.STORE.items())
|
||||
ok("test setup: affordable listing exists", price <= coins0, f"price {price} coins {coins0}")
|
||||
|
||||
# BUY NOW
|
||||
code, resp = u.trade_route(FakeH("POST", f"/ut/game/fifa17/trade/{tid}/bid",
|
||||
json.dumps({"bid": price}).encode()))
|
||||
r = resp.get("auctionInfo", [{}])[0]
|
||||
ok("buy returns 200", code == 200, str(code))
|
||||
ok("buy auction closed", r.get("tradeState") == "closed", r.get("tradeState"))
|
||||
ok("buy bidState highest", r.get("bidState") == "highest", r.get("bidState"))
|
||||
ok("coins deducted by buyNow", u.STORE.coins() == coins0 - price,
|
||||
f"{coins0}->{u.STORE.coins()} price {price}")
|
||||
ok("won card added to club", len(u.STORE.items()) == items0 + 1,
|
||||
f"{items0}->{len(u.STORE.items())}")
|
||||
ok("won itemData is object", isinstance(r.get("itemData"), dict))
|
||||
ok("won itemData.attributeList is array", isinstance(r.get("itemData", {}).get("attributeList"), list))
|
||||
ok("credits is int", isinstance(resp.get("credits"), int))
|
||||
|
||||
# GET view of an auction
|
||||
_, v = u.trade_route(FakeH("GET", f"/ut/game/fifa17/trade/{tid}"))
|
||||
ok("view auctionInfo is array", isinstance(v.get("auctionInfo"), list))
|
||||
|
||||
# insufficient funds -> 461, coins unchanged
|
||||
u.STORE._p["coins"] = 10
|
||||
u.STORE._save()
|
||||
c2, _ = u.trade_route(FakeH("POST", f"/ut/game/fifa17/trade/{tid}/bid", b'{"bid":999999}'))
|
||||
ok("insufficient funds -> 461", c2 == 461, str(c2))
|
||||
ok("coins unchanged on failed buy", u.STORE.coins() == 10, str(u.STORE.coins()))
|
||||
|
||||
os.remove(os.environ["FUT_PROFILE"])
|
||||
print(f"{'PASS' if not fails else 'FAIL'} - market buy flow "
|
||||
f"({0 if fails else 'all'} checks; {len(fails)} failed)")
|
||||
for f in fails:
|
||||
print(" FAIL:", f)
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -365,6 +365,14 @@ def _market_auctions(limit=21):
|
||||
for i in range(min(limit, len(PACK_POOL)))]
|
||||
|
||||
|
||||
def _auction_by_tradeid(tid):
|
||||
# tradeId space is _TRADE_ID_BASE + index into PACK_POOL -> reconstruct the auction.
|
||||
i = tid - _TRADE_ID_BASE
|
||||
if 0 <= i < len(PACK_POOL):
|
||||
return _auction_record(i, PACK_POOL[i])
|
||||
return None
|
||||
|
||||
|
||||
def _market_body(auctions):
|
||||
return {"auctionInfo": auctions, "credits": STORE.coins(),
|
||||
"total": len(auctions), "duplicateItemIdList": []}
|
||||
@@ -385,10 +393,37 @@ def auctionhouse_route(h):
|
||||
|
||||
|
||||
def trade_route(h):
|
||||
# GET view one auction / POST place bid -> {auctionInfo:[record], credits}.
|
||||
# Echo a sample record so a viewed/bid auction resolves.
|
||||
rec = _market_auctions(1)
|
||||
return 200, {"auctionInfo": rec, "credits": STORE.coins()}
|
||||
# GET view one auction -> {auctionInfo:[record], credits}
|
||||
# POST/PUT place bid / buy-now -> stateful: on buy-now (bid >= buyNowPrice) deduct
|
||||
# coins, grant the won card to the club, echo the CLOSED auction. Reuses the
|
||||
# validated auction-record shape (0x18013e410) so it's freeze-safe; whether FIFA
|
||||
# surfaces the won item post-buy is functional (needs live test). FutISOfferTrade.
|
||||
m = re.search(r"/trade/(\d+)", h.path)
|
||||
tid = int(m.group(1)) if m else -1
|
||||
rec = _auction_by_tradeid(tid)
|
||||
if h.command in ("POST", "PUT"):
|
||||
try:
|
||||
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
body = {}
|
||||
if rec is None:
|
||||
return 200, {"auctionInfo": [], "credits": STORE.coins()}
|
||||
bid = body.get("bid") or rec["buyNowPrice"]
|
||||
if bid >= rec["buyNowPrice"]: # BUY NOW
|
||||
if not STORE.spend(rec["buyNowPrice"]):
|
||||
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
|
||||
won = dict(rec["itemData"]); won.pop("id", None)
|
||||
won["itemState"] = "free" # unassigned/won
|
||||
granted = STORE.add_items([won])[0]
|
||||
rec = dict(rec)
|
||||
rec.update({"tradeState": "closed", "bidState": "highest",
|
||||
"currentBid": rec["buyNowPrice"], "itemData": granted})
|
||||
log(" MARKET: bought tradeId %d for %d, coins=%d, card->club"
|
||||
% (tid, rec["currentBid"], STORE.coins()))
|
||||
else: # simple bid (we're sole bidder)
|
||||
rec = dict(rec); rec.update({"currentBid": bid, "bidState": "highest"})
|
||||
return 200, {"auctionInfo": [rec], "credits": STORE.coins()}
|
||||
return 200, {"auctionInfo": [rec] if rec else [], "credits": STORE.coins()}
|
||||
|
||||
|
||||
def tradepile_route(h):
|
||||
|
||||
Reference in New Issue
Block a user