diff --git a/fifa17-recon/tools/test_empty_mypacks.py b/fifa17-recon/tools/test_empty_mypacks.py new file mode 100755 index 0000000..f914443 --- /dev/null +++ b/fifa17-recon/tools/test_empty_mypacks.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Regression tests for the empty-My-Packs FIFA 17 compatibility workaround (bug 6c). + +Pins the behavior store_catalog() now depends on: +- unopenedPackIds == [] -> exactly one synthetic active `mypacks` placeholder id 65534 +- unopenedPackIds == [70] -> no synthetic placeholder; the genuine owned pack is shown +- synthetic id 65534 stays economy-safe (non-resolvable, non-openable, non-granting) +- normal store packs (1/5/6/7) are untouched by the empty-state behavior + +See docs/evidence/STORE_TILE_6C.md and FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md. +Standalone unit test in the project style: `python3 test_empty_mypacks.py`. +""" +import importlib +import os +import sys +import tempfile + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +if TOOLS not in sys.path: + sys.path.insert(0, TOOLS) + +SENTINEL_ID = 65534 + + +def _set_unopened(fut_store, ids): + """Deterministically set the active profile's owned unopened packs.""" + p = fut_store.STORE.load() + p["unopenedPackIds"] = list(ids) + fut_store.STORE._save() + + +def _mypacks(catalog): + return [p for p in catalog["purchase"] + if (p.get("displayGroup") or {}).get("value") == "mypacks"] + + +def _fake_request(command, body): + class _H: + pass + h = _H() + h.command = command + h._body = body + return h + + +def main(): + with tempfile.TemporaryDirectory() as state: + os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json") + os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts") + os.environ.pop("FUT_PROFILE", None) + + import fut_account + import fut_store + import fut_accounts + import utas_server + importlib.reload(fut_account) + importlib.reload(fut_store) + importlib.reload(fut_accounts) + importlib.reload(utas_server) + + fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"}) + catalog_ids = [p["id"] for p in fut_store.PACK_CATALOG] + + # ---- A. Empty unopened packs -> one active synthetic 65534 placeholder ---- + _set_unopened(fut_store, []) + utas_server._OPENED_PACK_GRACE.clear() + status, cat = utas_server.store_catalog(None) + assert status == 200 + myp = _mypacks(cat) + assert len(myp) == 1, "expected exactly one mypacks entry, got %r" % myp + s = myp[0] + assert s["id"] == SENTINEL_ID, s + assert s["state"] == "active", s # the P2 fix: active, not inactive + assert (s.get("displayGroup") or {}).get("value") == "mypacks", s + assert SENTINEL_ID not in catalog_ids, "65534 must not be in PACK_CATALOG" + assert fut_store.pack_by_id(SENTINEL_ID) is None + print("A empty-state active placeholder: PASS") + + # ---- D (empty half). Normal packs untouched in empty state ---- + norm = {p["id"]: p for p in cat["purchase"] if p["id"] in (1, 5, 6, 7)} + assert set(norm) == {1, 5, 6, 7}, sorted(norm) + assert all(norm[i]["state"] == "active" for i in norm), norm + assert norm[1]["packType"] == "BRONZE" and norm[1]["description"] == "Bronze Pack" + + # ---- B. Non-empty unopened packs -> NO synthetic; genuine owned pack shown ---- + _set_unopened(fut_store, [70]) + utas_server._OPENED_PACK_GRACE.clear() + status, cat = utas_server.store_catalog(None) + assert status == 200 + ids = [p["id"] for p in cat["purchase"]] + assert SENTINEL_ID not in ids, "synthetic placeholder must be suppressed when a pack exists" + myp = _mypacks(cat) + assert len(myp) == 1 and myp[0]["id"] == 70, myp + assert myp[0]["state"] == "active" and myp[0]["unopened"] is True, myp[0] + # normal packs still intact alongside the owned pack + assert {1, 5, 6, 7}.issubset(set(ids)), sorted(ids) + print("B non-empty-state genuine pack: PASS") + + # ---- C. Economy safety of the synthetic placeholder ---- + _set_unopened(fut_store, []) + utas_server._OPENED_PACK_GRACE.clear() + coins0 = fut_store.STORE.coins() + items0 = len(fut_store.STORE.items()) + next0 = fut_store.STORE.load()["nextItemId"] + + assert fut_store.pack_by_id(SENTINEL_ID) is None + + # store_buy: a confirmed-buy transaction for 65534 must be a no-op {} + status, body = utas_server.store_buy( + _fake_request("PUT", b'{"packId":65534,"state":"TRANSACTIONCREATED"}')) + assert status == 200 and body == {}, (status, body) + + # purchased_items: POST buy for 65534 must not open/grant anything + status, body = utas_server.purchased_items( + _fake_request("POST", b'{"packId":65534,"useCredits":1,"usePreOrder":0,"currency":"COINS"}')) + assert status == 200, (status, body) + assert "createPackResponse" not in body, body + + # 65534 cannot enter the owned-pack pile (not a catalog pack) + assert fut_store.STORE.grant_unopened_pack(SENTINEL_ID) is False + assert SENTINEL_ID not in fut_store.STORE.unopened_packs() + + # nothing mutated + assert fut_store.STORE.coins() == coins0, (fut_store.STORE.coins(), coins0) + assert len(fut_store.STORE.items()) == items0 + assert fut_store.STORE.load()["nextItemId"] == next0 + assert not any(i.get("id") == SENTINEL_ID or i.get("resourceId") == SENTINEL_ID + for i in fut_store.STORE.items()) + print("C economy safety (65534 non-openable / non-granting): PASS") + + print("empty My Packs compatibility: PASS") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/fifa17-recon/tools/utas_server.py b/fifa17-recon/tools/utas_server.py index 9140588..3ab9b0a 100755 --- a/fifa17-recon/tools/utas_server.py +++ b/fifa17-recon/tools/utas_server.py @@ -3426,12 +3426,31 @@ def store_catalog(h): 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. + # EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2). + # + # The Store/Scaleform path RESOLVES the `mypacks` category even when the + # account owns zero unopened packs (the category is chosen client-side from + # the movie's CATEGORY_ID -> screen+0x290; no server field gates it). + # CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null + # guard, so if no `mypacks` group exists the client CRASHES + # (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by + # minidump). We therefore MUST emit a `mypacks` group when empty. + # + # state="inactive" avoids the crash but makes the client report the pack + # unavailable immediately on Store entry and bounce to the Hub. state="active" + # keeps the group structurally valid AND lets the Store open normally; the + # empty tile renders as "0 items" and an explicit open is rejected + # CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend + # request and mutates nothing. + # + # id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns + # None and store_buy()/purchased_items() cannot open it, grant items/coins, + # or add it to unopenedPackIds. This is a compatibility shim for FIFA 17 + # client behavior, NOT an EA-authentic empty-My-Packs representation, and it + # is FIFA17-specific (do not lift into game-independent Core). A fully clean + # zero-pack UX requires a client-side fix -- see + # docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in + # docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md. sentinel = { "id": 65534, "name": "", @@ -3441,7 +3460,7 @@ def store_catalog(h): "specialChance": 0.0, } empty = _pack_body(sentinel, 1, owned=True) - empty["state"] = "inactive" + empty["state"] = "active" empty["unopened"] = False packs.append(empty) return 200, {"purchase": packs, "timestamp": 1596326400}