f42279f869
When the account owns zero unopened packs, store_catalog() emits a synthetic `mypacks` group placeholder (id 65534, absent from PACK_CATALOG). Change its state from "inactive" to "active".
Root cause (bug 6c): FIFA 17's Store/Scaleform path resolves the `mypacks` category even with zero unopened packs (category chosen client-side via 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 an absent group crashes the client (CardsDLL+0x14882, [NULL+0x48], minidump-confirmed). An inactive placeholder avoids the crash but makes the Store report the pack unavailable on entry and bounce to the Hub; an active placeholder lets the Store open normally.
65534 stays economy-safe: pack_by_id() returns None, so store_buy()/purchased_items() cannot open it or grant items/coins, and grant_unopened_pack() rejects it. Explicit selection is rejected client-side ("This pack is no longer available") and sends no backend request. This is a FIFA-17 client-compatibility shim (P2), not an EA-authentic representation, confined to the FIFA-17 backend (not OpenFUT Core). A clean zero-pack UX needs a client-side fix (docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md).
Adds regression tests (test_empty_mypacks.py): empty -> one active 65534 placeholder (absent from PACK_CATALOG); non-empty [70] -> no placeholder, genuine pack shown; economy safety; normal packs 1/5/6/7 untouched.
137 lines
5.5 KiB
Python
Executable File
137 lines
5.5 KiB
Python
Executable File
#!/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())
|