fifa17-recon: reconcile authoritative tools with running backend (B)

- Add 8 files present in docker/fifa17-python/tools but missing from the
  top-level tree: fut_accounts.py + 7 test_*.py contracts (all committed in
  the server's docker tree; byte-identical to the running image).
- Preserve newer responder work already matching the running container:
  utas_server.py (offlineSeason), lsx_responder_v2.py (OPENFUT_BIND),
  blaze_responder_v3b.py, autopatch.py, pow_server.py, fut_store.py,
  test_fut_contract.py, fifa17-hook-m1.sh.
- Add 30 newer ghidra_queries (draft purchase/state, SBC 9-26, runtime
  registries). Local tree is now a strict superset of B with all shared
  files byte-identical.
This commit is contained in:
funman300
2026-08-10 17:08:06 -07:00
parent 622a774f6a
commit 8cba70dc90
44 changed files with 1742 additions and 84 deletions
+345 -54
View File
@@ -11,11 +11,13 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
"""
import datetime, json, os, re, sys, http.server
import copy, datetime, json, os, random, re, sys, http.server
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from fut_seed import CLUB, SQUAD, USER_LIST, squad_summary # forged starter squad (clean-room)
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item # profile + packs
from fut_store import STORE, PACK_CATALOG, pack_by_id, PACK_POOL, _item, player_item
import fut_cards
import fut_staff
from fut_account import ACCOUNT, validate_club # identity + club, single source
# FUT_PORT exists so a second, THROWAWAY instance can be started without touching the
@@ -37,6 +39,16 @@ SID = "OPENFUT-SID-0000000000000001"
# Flip to True once you want to exercise the create-club path instead.
NEW_USER = False
# An owned pack is consumed persistently when opened, but FIFA's reveal controller
# still returns to the My Packs group after all items are assigned/sold. Keep the
# just-opened catalogue record visible until the next hub request so that group is
# not deleted underneath a live UI controller.
_OPENED_PACK_GRACE = []
def visible_unopened_packs():
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
def now():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
@@ -285,10 +297,16 @@ def user_info():
"actives": (current_squad().get("actives") or [])[:5],
})
# ---- the two side-effecting members, off by default (see _UI above) --------
if _UI in ("packs", "full"):
# Ownership counters must reflect persistent inventory only. The catalogue
# grace row prevents StoreFront from deleting a group beneath its live reveal
# controller, but the pack was already consumed and must not remain in FIFA's
# cached unopened-pack count.
unopened_count = len(STORE.unopened_packs())
if unopened_count or _UI in ("packs", "full"):
# unopenedPacks(0x35e): after parsing preOrderPacks(0x24b)+recoveredPacks
# (0x27b) the deser calls singleton->vtbl[0x4e0](preOrder + recovered).
info["unopenedPacks"] = {"preOrderPacks": 0, "recoveredPacks": 0}
info["unopenedPacks"] = {"preOrderPacks": 0,
"recoveredPacks": unopened_count}
if _UI in ("roster", "full"):
# squadList(0x2d4) -> FUN_180142260 on singleton->vtbl[0x480]+0x30, i.e. it
# fills the global squad-ROSTER model ("MY SQUADS" on the Squads screen).
@@ -936,13 +954,30 @@ def quick_sell_route(h):
except Exception:
body = {}
ids = [it.get("id") for it in (body.get("itemData") or []) if isinstance(it, dict)]
# Live FIFA 17 bulk serializer FUN_180126f40 emits the singular atom
# `itemId` containing an array of int64 handles. Keep itemIds as a tolerant
# alias for old replay fixtures, but never rely on it for the retail client.
if not ids and isinstance(body.get("itemId"), list):
ids = body["itemId"]
if not ids and isinstance(body.get("itemIds"), list):
ids = body["itemIds"]
ids = [int(i) for i in ids if isinstance(i, int) and i > 0]
sellable_before = {it.get("id") for it in STORE.purchased() + STORE.items()}
sold, coins = STORE.quick_sell(ids)
if sold:
log(" QUICKSELL: sold %d card(s) for %d coins (total %d)"
% (sold, coins, STORE.coins()))
return 200, {}
else:
log(" QUICKSELL: no requested ids were found; balance unchanged at %d"
% STORE.coins())
# FutDiscardCardServerResponse. `totalCredits` is the absolute post-sale
# wallet balance, not the sale delta. Only echo accounted-for IDs; duplicate
# or stale request handles must not be removed from the client model twice.
sold_ids = list(dict.fromkeys(iid for iid in ids if iid in sellable_before))
return 200, {
"items": [{"id": iid} for iid in sold_ids],
"totalCredits": STORE.coins(),
}
def _move_ack(req, moved):
@@ -1116,6 +1151,19 @@ ROUTES = [
# is composed by appending a suffix, so it is invisible to the request-template
# table, and the generic /squad route below was swallowing it. MUST precede it.
(re.compile(G + r"/squad/mode/draft/state"), lambda m, h: draft_state_route(h)),
# Live-composed Draft URL, likewise invisible in the static request templates.
# It must precede generic /squad or squad_route answers {"id":0}, leaving the
# formation carousel empty even though FORMATION_DRAFT was accepted.
(re.compile(G + r"/squad/mode/\d+/draft/choices/formation"),
lambda m, h: draft_formation_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/captain"),
lambda m, h: draft_captain_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/player"),
lambda m, h: draft_player_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choices/manager"),
lambda m, h: draft_manager_choices_route(h)),
(re.compile(G + r"/squad/mode/\d+/draft/choose"),
lambda m, h: draft_choose_route(h)),
(re.compile(G + r"/purchase/mode/\d+/draft"), lambda m, h: draft_purchase_route(h)),
(re.compile(G + r"/squad"), lambda m, h: squad_route(h)),
(re.compile(G + r"/match/keepalive"), lambda m, h: (204, None)),
@@ -1286,6 +1334,10 @@ def hub_data():
showed it) yet the TRANSFER LIST tile read '0 items / Selling 0' -- the tile reads
hub.tradePile, not /tradePile/counts (which the tile never re-polls). All active
listings are 'selling'; none are 'sold'. count == selling == number of listings."""
if _OPENED_PACK_GRACE:
log(" STORE: retiring %d opened-pack grace entry at hub"
% len(_OPENED_PACK_GRACE))
_OPENED_PACK_GRACE.clear()
if not HUBDATA:
return {}
players = len([i for i in STORE.items() if _is_player(i)])
@@ -2522,25 +2574,214 @@ def sbc_tag_route(h):
# Draft cannot be entered at all today. FUT_DRAFT_STATE=0 restores the old routing if
# this turns out to be wrong.
DRAFT_STATE = os.environ.get("FUT_DRAFT_STATE", "1") == "1"
# Modes that successfully passed the entry-purchase response in this server process.
# Keep this volatile until the complete draft lifecycle (including abandon/rewards)
# is implemented; persisting a half-built draft would make recovery harder.
_DRAFT_SESSIONS = {}
def _draft_squad(session):
"""A Draft-owned squad model, separate from STORE's regular active squad."""
squad = copy.deepcopy(SQUAD)
squad.update({
"id": 0,
"personaId": ACCOUNT.persona_id,
"squadName": "My Draft",
"formation": session.get("formation", "f442"),
"squadType": "DRAFT_SQUAD",
"chemistry": 0,
"starRating": 0,
"captain": 0,
"manager": ([{
"id": session["manager"].get("id", 0),
"itemData": copy.deepcopy(session["manager"]),
"dream": False,
}] if session.get("manager") else []),
})
# SQUAD is currently the empty, schema-proven seed, but explicitly stripping
# itemData prevents a future seed-mode change from leaking the regular XI here.
selected = session.get("selected", {})
squad["players"] = []
for index in range(23):
player = {"index": index, "kitNumber": 0}
if index in selected:
player["itemData"] = copy.deepcopy(selected[index])
squad["players"].append(player)
captain_slot = session.get("captain_slot")
if captain_slot in selected:
squad["captain"] = selected[captain_slot].get("id", 0)
return squad
def draft_state_route(h):
if not DRAFT_STATE:
return squad_route(h)
m = re.search(r"[?&]mode=([^&]+)", h.path)
mode = m.group(1) if m else ""
session = _DRAFT_SESSIONS.get(mode)
purchased = session is not None
return 200, [{
"squadState": "INVALID", # 0x2d5 STRING enum
# Atom-table-backed enum consumed by FUN_180147070. After a successful
# entry purchase FIFA's next legitimate stage is formation selection.
"squadState": session.get("stage", "FORMATION_DRAFT") if purchased else "INVALID",
"stateParam1": "INVALID", # STRING
"stateParam2": "0", # STRING (the int getter also accepts it)
"gamesWonCurrentMatch": 0, # INT
"roundsInfo": [], # array of the 7-scalar element; empty is safe
**({"squad": _draft_squad(session)} if purchased else {}),
# entranceCriteria: OMITTED. Shape known, not needed, skip-safe.
}]
def draft_formation_choices_route(h):
"""Return the first Draft round: a formation carousel.
FutGetDraftChoicesServerResponse (FUN_18014f2d0) consumes an object root with
choices[] records. Formation records use only index + formation; itemData is
reserved for later player/manager rounds.
"""
log(" DRAFT: serving formation choices")
return 200, {
"positionid": 0,
"tier": 1,
"choices": [
{"index": 0, "formation": "f442"},
{"index": 1, "formation": "f433"},
],
}
def draft_captain_choices_route(h):
"""Offer five known-good player cards for the captain round."""
candidates = [player for player in fut_cards.POOL if player[1] >= 84]
cards = [player_item(800000000 + index, player, special=random.random() < 0.20)
for index, player in enumerate(random.sample(candidates, 5))]
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "CAPTAIN_DRAFT"})
session["pending_choices"] = cards
log(" DRAFT: serving %d captain choices" % len(cards))
return 200, {
"positionid": 0,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
def draft_player_choices_route(h):
"""Offer a player round for the slot requested by the Draft UI."""
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
position_id = int(body.get("positionId", body.get("positionid", 0)))
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "PLAYER_DRAFT"})
selected_assets = {
card.get("assetId") for card in session.get("selected", {}).values()
}
def draft_manager_choices_route(h):
"""Offer five verified FIFA 17 managercards for the final Draft round."""
m = re.search(r"/squad/mode/(\d+)/draft/", h.path)
mode = "SINGLE_PLAYER" if (m and m.group(1) == "1") else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "MANAGER_DRAFT"})
manager_ids = random.sample(fut_staff.STARTER_MANAGERS, 5)
cards = [fut_staff.manager_item(800200000 + index, carddbid)
for index, carddbid in enumerate(manager_ids)]
session["stage"] = "MANAGER_DRAFT"
session["pending_choices"] = cards
session["pending_position"] = 23
log(" DRAFT: serving %d manager choices" % len(cards))
return 200, {
"positionid": 23,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
# Prefer the requested slot's broad position family. If FIFA sends only a
# numeric squad slot (as observed), the client still enforces chemistry/fit;
# offering varied high-quality players is safer than inventing a slot map for
# every formation. Five unique assets are guaranteed per carousel.
candidates = [player for player in fut_cards.POOL
if player[1] >= 75 and player[0] not in selected_assets]
picks = random.sample(candidates, 5)
draft_seq = session.get("draft_item_seq", 0)
cards = [player_item(800100000 + draft_seq + index, player,
special=random.random() < 0.12)
for index, player in enumerate(picks)]
session["draft_item_seq"] = draft_seq + len(cards)
session["pending_choices"] = cards
session["pending_position"] = position_id
log(" DRAFT: serving %d player choices for slot %d"
% (len(cards), position_id))
return 200, {
"positionid": position_id,
"tier": 1,
"choices": [
{"index": index, "itemData": card}
for index, card in enumerate(cards)
],
}
def draft_choose_route(h):
"""Acknowledge a pick and advance the volatile Draft state machine."""
try:
body = json.loads(h._body.decode()) if getattr(h, "_body", b"") else {}
except Exception:
body = {}
m = re.search(r"/squad/mode/(\d+)/draft/choose", h.path)
mode_id = int(m.group(1)) if m else 0
mode = "SINGLE_PLAYER" if mode_id == 1 else "ONLINE"
session = _DRAFT_SESSIONS.setdefault(mode, {"stage": "FORMATION_DRAFT"})
if session.get("stage") == "FORMATION_DRAFT":
formations = ("f442", "f433")
choice = body.get("choiceIndex", 0)
session["formation"] = formations[choice] if choice in range(len(formations)) else "f442"
session["stage"] = "CAPTAIN_DRAFT"
log(" DRAFT: chose formation %s; advancing to captain"
% session["formation"])
elif session.get("stage") in ("CAPTAIN_DRAFT", "PLAYER_DRAFT", "MANAGER_DRAFT"):
choice_index = int(body.get("choiceIndex", 0))
position_id = int(body.get("positionId", session.get("pending_position", 0)))
choices = session.get("pending_choices", [])
if 0 <= choice_index < len(choices):
session.setdefault("selected", {})[position_id] = copy.deepcopy(
choices[choice_index]
)
if session.get("stage") == "CAPTAIN_DRAFT":
session["captain_slot"] = position_id
session["stage"] = "PLAYER_DRAFT"
log(" DRAFT: chose captain for slot %d; advancing to players"
% position_id)
elif session.get("stage") == "MANAGER_DRAFT":
session["manager"] = copy.deepcopy(choices[choice_index])
session["stage"] = "COMPLETED_DRAFT"
log(" DRAFT: chose manager; draft squad is complete")
else:
log(" DRAFT: chose player for slot %d" % position_id)
else:
log(" DRAFT: rejected out-of-range choice %d at slot %d"
% (choice_index, position_id))
else:
log(" DRAFT: acknowledged pick at stage %s body=%s"
% (session.get("stage"), json.dumps(body)))
# FutPickDraftChoiceServerResponse uses the generic no-field response parser.
return 200, {}
# ---- Draft entry purchase ----------------------------------------------------
# POST ut/%s/purchase/mode/{price}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. "Buys" entry into draft mode and returns
# the fresh draft session summary.
# POST ut/%s/purchase/mode/{mode}/draft body {"currency":"COINS","usePreOrder":0}
# -> FutPurchaseDraftModeServerResponse. The path component is the draft mode
# (1 for SINGLE_PLAYER), not the entry price.
#
# LIVE 2026-08-04: this endpoint was UNMAPPED, answered {} by the catch-all, and the
# client CRASHED immediately after. Sequence, from the log:
@@ -2552,33 +2793,22 @@ def draft_state_route(h):
# unimplemented call, which is the outcome a correct fix is supposed to have.
#
# WHICH ENVELOPE. ENDPOINT_MAP flags a "response-variant ambiguity" here: two
# structures reference the class name. Resolved this session, and the doc's note about
# the second one is wrong:
# structures reference the class name. Live behaviour plus the request vtable resolves
# the POST to the second variant:
# 0x18014c260 (vtable 0x180224ef8, factory 0x18014c090) 3188 chars, OBJECT root
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and exactly
# the seven scalar ints below. THIS IS THE RESPONSE PARSER.
# (prologue tests != 10 = END_OBJECT), 1 skip-handler call, and seven
# scalar ints. This is a distinct response using the same class name.
# 0x180150310 (vtable 0x1802262f0, factory 0x180150260) 1836 chars, ARRAY root
# (loops until 0xd = END_ARRAY), ZERO skip handlers -- and it is not a
# response root at all. It parses ENTRANCE CRITERIA: each element's
# name is strcmp'd against the literals "COINS", "POINTS" and
# "DRAFT_TOKEN" and stored at +0x28/+0x2c/+0x30. It shares the name
# string because it is the fee sub-object, not an alternate envelope.
# (loops until 0xd = END_ARRAY), ZERO skip handlers. Each element is
# parsed by FUN_180138bd0 as name/funds/finalFunds, then name is compared
# with "COINS", "POINTS", and "DRAFT_TOKEN". Request vtable
# 0x180226300 selects factory 0x180150260 for the live purchase POST.
#
# THE CRASH ITSELF DISCRIMINATES, which is worth recording as a technique. An
# object-root parser handed {} parses benignly and leaves defaults; an array-root
# parser handed {} desyncs and HANGS, which is exactly what draft/state did before it
# was fixed. We observed a CRASH, not a hang, so the object-root parser is what ran,
# and the failure is downstream of an empty-but-valid parse. That is consistent with
# 0x18014c260 and inconsistent with 0x180150310.
#
# All seven members are scalar ints and the skip handler is present, so unknown keys
# are inert and there is no freeze surface here.
#
# NOT DEDUCTED FROM COINS. The client posts {"currency":"COINS"} with the price in the
# URL, and the price it sent was 0 because we omit entranceCriteria from draft/state,
# so there is no fee to charge yet. Charging a guessed amount would be inventing an
# economy rule; when entranceCriteria is served the price becomes real and this is the
# place to take it.
# LIVE 2026-08-07: returning the seven-int object made FIFA consume the POST (HTTP
# 200), issue no follow-up request, and spin at high CPU. That is the array parser's
# exact EOF-loop signature. The response below therefore uses the required array root.
# No coins are deducted yet: the emulator has not served a verified entrance price,
# and the path's mode id must not be mistaken for a price.
#
# DEFAULT ON for the same reason as FUT_DRAFT_STATE: the current behaviour is a
# confirmed crash, so there is no working state being protected.
@@ -2593,18 +2823,18 @@ def draft_purchase_route(h):
# calling .group() on the first argument. Doing that raised AttributeError,
# which killed the connection outright -- strictly worse than the {} it replaced.
m = re.search(r"/purchase/mode/(\d+)/draft", h.path)
price = int(m.group(1)) if m else 0
log(" DRAFT: purchase entry, price=%d (not deducted -- no entranceCriteria "
"served yet, so the client posted its own price)" % price)
return 200, {
"championEventId": 0,
"expectedTierLevel": 1,
"gamesPlayed": 0,
"gamesRemaining": 4, # a draft run is 4 rounds
"rank": 0,
"score": 0,
"tierLevel": 1,
}
mode = int(m.group(1)) if m else 0
mode_name = "SINGLE_PLAYER" if mode == 1 else "ONLINE"
_DRAFT_SESSIONS[mode_name] = {"stage": "FORMATION_DRAFT", "formation": "f442"}
coins = STORE.coins()
points = STORE.profile().get("points", 0)
log(" DRAFT: purchase entry, mode=%d; returning array-root currency result "
"(entry fee not deducted until entrance criteria are verified)" % mode)
return 200, [
{"name": "COINS", "funds": coins, "finalFunds": coins},
{"name": "POINTS", "funds": points, "finalFunds": points},
{"name": "DRAFT_TOKEN", "funds": 0, "finalFunds": 0},
]
def champion_route(h):
@@ -2866,7 +3096,7 @@ def _probe_final_funds(p):
return p["price"]
def _pack_body(p, idx):
def _pack_body(p, idx, owned=False):
"""One entry of FutStoreGetPackTypes.purchase (element deser 0x18013af30).
THIS IS THE ORIGINAL, KNOWN-GOOD BODY -- restored 2026-08-04 after my "field
@@ -2922,10 +3152,20 @@ def _pack_body(p, idx):
"goldQuantity": p["count"] if gold else 0,
"rareQuantity": p["count"] if gold else 0,
"itemQuantity": p["count"],
"unopened": False,
},
# This is a top-level BOOL in the 0x158-byte pack record. Nesting it in
# packContentInfo (the old code) is skip-safe but completely inert.
"unopened": bool(owned),
}
if STORE_DISPLAYGROUP:
if owned:
# Reward packs are opened with usePreOrder=1 and have no purchase path.
# Leaving zero-value coin/mtx objects attached makes the My Packs tile
# format an unavailable payment label as the literal "undefined".
body.pop("currencies", None)
body.pop("extPrice", None)
if owned:
body["displayGroup"] = {"value": "mypacks", "priority": idx}
elif STORE_DISPLAYGROUP:
# THE "unknown" FIX. displayGroup(0xd9) is parsed INLINE as a FLAT OBJECT --
# it is NOT recursive, the case-0xd9 body never re-enters 0x18013af30, so the
# recursion the old notes assumed does not exist and there was never anything
@@ -2955,7 +3195,19 @@ def _pack_body(p, idx):
# than a caption, the store goes from ugly-but-working to unusable. Default OFF
# for exactly that reason, and the live test buys a pack to prove the buy path
# still works.
body["displayGroup"] = {"value": p["name"]}
# FIFA 17's StoreFront does not treat this value as an arbitrary caption.
# It resolves exactly six hard-coded category tokens: mypacks, points,
# bronze, silver, gold and special (FUN_180014580/FUN_180014df0). Pack
# titles here create unsupported pseudo-categories and make GOTO_STORE_MYPACK
# initially land on the all-groups screen. Keep ordinary packs in the
# client's canonical categories; `description` remains the per-pack title.
if p.get("specialChance", 0.0) >= 1.0:
category = "special"
elif gold:
category = "gold"
else:
category = "bronze"
body["displayGroup"] = {"value": category}
# FUT_STORE_GROUPID. The risk flagged above ACTUALLY HAPPENED, live 2026-08-05:
# sending displayGroup did switch the store to a grouped render path, all three
# packs collapsed into ONE group, and drilling into any of the three group tiles
@@ -2997,7 +3249,32 @@ def store_catalog(h):
consumed -- an infinite loop inside FUN_1801c7f10, whose body contains the spin PC
0x1801c7f1a that was observed live. Not a mystery freeze; a traced one.
"""
packs = [_pack_body(p, idx) for idx, p in enumerate(PACK_CATALOG, start=1)]
normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]
packs = [_pack_body(p, idx) for idx, p in enumerate(normal, start=1)]
owned_ids = visible_unopened_packs()
for idx, pack_id in enumerate(owned_ids, start=1):
owned = pack_by_id(pack_id)
if owned:
packs.append(_pack_body(owned, idx, owned=True))
if not owned_ids:
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
# renders rows. If the group is absent FIFA falls back to Bronze and
# shows the empty-category dialog over the wrong tab. Retain an inactive
# zero-item sentinel so the destination resolves, while state != active
# keeps it out of the visible row list. Its id is deliberately absent
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
sentinel = {
"id": 65534,
"name": "",
"price": 0,
"count": 0,
"gold": True,
"specialChance": 0.0,
}
empty = _pack_body(sentinel, 1, owned=True)
empty["state"] = "inactive"
empty["unopened"] = False
packs.append(empty)
return 200, {"purchase": packs, "timestamp": 1596326400}
@@ -3015,10 +3292,11 @@ def store_buy(h):
if body.get("state") == "TRANSACTIONCANCEL" or not isinstance(pid, int):
return 200, {} # not a confirmed buy
pack = pack_by_id(pid)
if not pack:
if not pack or pack.get("ownedOnly"):
return 200, {}
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers"))
pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: opened pack %s -> %d items, coins=%d" % (pack["name"], len(items), STORE.coins()))
@@ -3047,8 +3325,14 @@ def purchased_items(h):
pack = pack_by_id(pid) if isinstance(pid, int) else None
if pack is None:
return 200, {"itemData": STORE.last_pack()}
if pack.get("ownedOnly") and not STORE.consume_unopened_pack(pid):
log(" STORE: rejected unopened pack %s; no owned instance" % pid)
return 200, {"itemData": STORE.last_pack()}
if pack.get("ownedOnly"):
_OPENED_PACK_GRACE.append(pid)
items = STORE.open_pack(pack["price"], pack["count"], pack["gold"],
pack.get("tiers"))
pack.get("tiers"), pack.get("specialChance", 0.0),
pack.get("playersOnly", False))
if items is None:
return 461, {"reason": "insufficient_coins", "credits": STORE.coins()}
log(" STORE: POST /purchased opened pack %s -> %d items, coins=%d"
@@ -3072,13 +3356,20 @@ def credits_route(h):
# The FUT hub coin counter binds to currencies[].funds (deser 0x180122c50,
# atom "currencies" 0xc5), NOT a "credits" key -- wf_76fcf89b.
c = STORE.coins()
return 200, {
body = {
"credits": c,
"currencies": [
{"name": "coins", "funds": c, "finalFunds": c},
{"name": "points", "funds": 0, "finalFunds": 0},
],
}
# Do not count _OPENED_PACK_GRACE here: it is a UI-lifetime catalogue shim,
# not an owned pack. Reporting it would leave the My Packs badge stuck at 1.
unopened_count = len(STORE.unopened_packs())
if unopened_count:
body["unopenedPacks"] = {"preOrderPacks": 0,
"recoveredPacks": unopened_count}
return 200, body
# ---- TRANSFER MARKET / AUCTION HOUSE (ENDPOINT_MAP market §) ----------------