diff --git a/fifa17-recon/tools/test_fut_contract.py b/fifa17-recon/tools/test_fut_contract.py index 9bdb1fc..ada235c 100644 --- a/fifa17-recon/tools/test_fut_contract.py +++ b/fifa17-recon/tools/test_fut_contract.py @@ -86,6 +86,29 @@ def test_market_bodies(): check("marketdata maxPrice is number", is_num(md.get("maxPrice"))) +def test_auction_record_shape(): + # Every populated auction record MUST match the reversed schema (deser + # 0x18013e410) field-for-field, or the market screen freezes. This proves the + # sample listings are freeze-safe OFFLINE, before the game ever parses them. + d = _get(G + "/auctionhouse?type=player&start=0&num=21") + recs = d.get("auctionInfo", []) + check("auctionhouse returns >=1 listing (or FUT_MARKET=empty)", is_arr(recs)) + numeric = ["tradeId", "buyNowPrice", "startingBid", "currentBid", "expires", + "sellerEstablished", "coinsProcessed"] + strings = ["tradeState", "bidState", "sellerName"] + for r in recs: + check("record.itemData is object", is_obj(r.get("itemData")), repr(type(r.get("itemData")))) + check("record.watched is bool", isinstance(r.get("watched"), bool), repr(r.get("watched"))) + for k in numeric: + check(f"record.{k} is number", is_num(r.get(k)), repr(r.get(k))) + for k in strings: + check(f"record.{k} is string", is_str(r.get(k)), repr(r.get(k))) + # itemData must itself be a valid card object (reuses club/squad parser) + it = r.get("itemData", {}) + check("record.itemData.attributeList is array", is_arr(it.get("attributeList"))) + check("record.itemData.resourceId is number", is_num(it.get("resourceId"))) + + def test_squad_boot(): # LoadActiveSquad (deser 0x18013d1f0): players MUST be array; empty body would reset # the 23 slots. formation is a string. This is the boot-critical path. @@ -115,7 +138,7 @@ def test_club_items(): def main(): tests = [test_credits, test_v2_store_gate, test_store_catalog, test_market_bodies, - test_squad_boot, test_massinfo_empty, test_club_items] + test_auction_record_shape, test_squad_boot, test_massinfo_empty, test_club_items] try: _get(G + "/user/credits") except Exception as e: diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 59909ca..08df166 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -15,7 +15,7 @@ import datetime, json, os, re, sys, http.server sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room) -from fut_store import STORE, PACK_CATALOG, pack_by_id # persistent profile + packs +from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs ADDR = ("127.0.0.1", 8099) LOG = "/tmp/utas_server.log" @@ -318,40 +318,88 @@ def credits_route(h): # listings yet): empty arrays never desync, so this is freeze-safe. Populating real # auctions needs an in-game test pass. Extra keys are SKIP'd, so one merged body # safely satisfies both the search parser and the auction-count parser. -def _market_body(): - return {"auctionInfo": [], "credits": STORE.coins(), "total": 0, - "duplicateItemIdList": []} +# Sample auction listings (real players from the pack pool) so the market is +# browsable/buyable. Each record follows the reversed auction schema (deser +# 0x18013e410) EXACTLY; itemData reuses fut_store._item -- the same proven-safe +# card shape that renders club/squad cards (parser 0x18013fe00). All record +# fields are HIGH-confidence reversed scalars, so freeze risk is low. Toggle with +# FUT_MARKET=empty. Price heuristic: rating-based buy-now, ~66% starting bid. +_MARKET_MODE = os.environ.get("FUT_MARKET", "sample") +_TRADE_ID_BASE = 900000000 + + +def _price_for(rating): + if rating >= 90: return 25000 + if rating >= 85: return 8000 + if rating >= 80: return 2500 + if rating >= 75: return 900 + return 400 + + +def _auction_record(i, defn): + asset, rating, pos, nation, league, team, attrs = defn + buy = _price_for(rating) + card = _item(_TRADE_ID_BASE + 100000 + i, asset, rating, pos, nation, league, team, attrs) + card["untradeable"] = False # market cards are tradeable + card["itemState"] = "forSale" + return { + "tradeId": _TRADE_ID_BASE + i, + "itemData": card, # OBJECT (0x18013fe00) -- freeze-safe + "tradeState": "active", # enum string + "buyNowPrice": buy, + "startingBid": max(150, (buy * 2) // 3), + "currentBid": 0, + "bidState": "none", # enum string + "expires": 3600, # SECONDS remaining (not epoch) + "sellerName": "EASFC", + "sellerEstablished": 1, + "watched": False, + "coinsProcessed": 0, + } + + +def _market_auctions(limit=21): + if _MARKET_MODE == "empty": + return [] + return [_auction_record(i, PACK_POOL[i % len(PACK_POOL)]) + for i in range(min(limit, len(PACK_POOL)))] + + +def _market_body(auctions): + return {"auctionInfo": auctions, "credits": STORE.coins(), + "total": len(auctions), "duplicateItemIdList": []} def auctionhouse_route(h): - # GET = search (empty results) OR count -> merged body (extra keys skip) - # POST = FutISStart (list item for sale) -> {"id": new tradeId} - # PUT = .../relist (relist all expired) -> ack {} + # GET = search (sample listings) OR count -> merged body (extra keys skip) + # POST = FutISStart (list item for sale) -> {"id": new tradeId} + # PUT = .../relist (relist all expired) -> ack {} if h.command == "POST": return 200, {"id": STORE.new_item_id()} if h.command == "PUT": return 200, {} - body = _market_body() + body = _market_body(_market_auctions()) body.update({"count": 0, "maxAuctionsAllowed": 100, "offered": 0, "selling": 0, "sold": 0}) # FutGetAuctionCount ints return 200, body def trade_route(h): - # GET view one auction / POST place bid -> {auctionInfo:[record], credits} - return 200, {"auctionInfo": [], "credits": STORE.coins()} + # 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()} def tradepile_route(h): - b = _market_body(); b.pop("duplicateItemIdList", None) - return 200, b # {auctionInfo, credits, total} + # The user's OWN sale pile -- empty until they list something (no live sell flow yet). + return 200, {"auctionInfo": [], "credits": STORE.coins(), "total": 0} def watchlist_route(h): if h.command in ("PUT", "POST", "DELETE"): return 200, {} # add/remove watch -> ack - b = _market_body(); b.pop("duplicateItemIdList", None) - return 200, b + return 200, {"auctionInfo": [], "credits": STORE.coins(), "total": 0} class H(http.server.BaseHTTPRequestHandler):