"""Persistent FUT profile store for the FIFA 17 offline backend (OpenFUT). A single JSON file holds the user's save: coins/points, owned club items, squads, and record. Loaded once, saved on every mutation. This is the "user + saved files" layer; packs and the starter grant build on it. (Later this gets ported into openfut-core's SQLite backend behind a FIFA-17 bridge; JSON keeps iteration fast while we nail the wire format.) Item shape mirrors what /club and /squad already serve (fut_seed.player_item): resourceId/assetId + attrs; identity (name/photo/club/nation) resolves locally in FIFA from dbdata.dll on the club-search/add path (see docs/CARD_SYSTEM.md). """ import json, os, sys, threading HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, HERE) import fut_cards from fut_account import ACCOUNT # single source of truth for identity/club PROFILE_ROOT = os.environ.get("FUT_PROFILE_ROOT", "") def profile_path_for(persona_id): explicit = os.environ.get("FUT_PROFILE") if explicit: return explicit if PROFILE_ROOT: return os.path.join(PROFILE_ROOT, str(int(persona_id)), "fifa17_profile.json") return os.path.join(HERE, "fifa17_profile.json") PROFILE_PATH = profile_path_for(ACCOUNT.persona_id) # ---- FUT_DISCARD_TABLE: the REAL FIFA 17 quick-sell values ------------------ # # quick_sell() used to pay an invented rating tier (600/300/150/50). That number # was wrong for every card. The real table is `fcc_discardcoins` in the client's # own game DB, 141 rows keyed (cardtype, level, rare) -> price, recovered from the # running client 2026-08-05 and verified against 22 live club items, 22/22 exact. # # The client computes the DISPLAYED value itself with the same table whenever our # `discardValue` (atom 0xd7) is 0 or absent: FUN_18013fe00 stores our value at item # +0x38, and the guard at 0x180141025 (`cmp dword [rbp+0x198],0` / `ja`) skips the # local computation when it is non-zero. So today the client shows the real value # while the server pays a made-up one, and the two disagree on every card. This # makes the paid value agree with the shown value. # # value = round_half_up(rating * price / 100) # level = 3 if rating >= 75, 2 if 65..74, else 1 (0x180141e8a..0x180141ea3; # derived from rating, NOT a wire field) # cardtype = FUN_1800d8330(cardsubtypeid), decoded from its jump table and # checked across every subtype 0..599 with zero disagreements # # ZERO WIRE CHANGE. Nothing new is sent; only the coin figure the server credits # changes. Default off per the house rule, but this is the one patch worth # defaulting on after a single verification. # See docs/plan-2026-08-05-store-subsystem.md section 3.6. DISCARD_TABLE = os.environ.get("FUT_DISCARD_TABLE", "0") == "1" _DP = {} def _dp(ct, rares, p1, p2, p3): for r in rares: _DP[(ct, 1, r)] = p1 _DP[(ct, 2, r)] = p2 _DP[(ct, 3, r)] = p3 _dp(1, [0], 30, 150, 400) _dp(1, [1], 75, 350, 800) _dp(1, [7], 1500, 5000, 9000) _dp(1, [2, 3, 10, 13] + list(range(17, 32)), 2000, 7000, 12200) _dp(1, [4, 8, 9], 6000, 10000, 18000) _dp(1, [11], 10000, 15000, 24000) _dp(1, [5, 6], 20000, 40000, 80000) _dp(1, [12], 120000, 120000, 120000) _dp(2, [0], 20, 70, 110) _dp(2, [1], 25, 120, 320) for _ct in (3, 4, 5, 10): _dp(_ct, [0], 10, 55, 110) _dp(_ct, [1], 50, 100, 300) for _ct in (6, 7, 8, 9): _dp(_ct, [0], 5, 20, 40) _dp(_ct, [1], 20, 50, 70) def _cardtype(sub): """FUN_1800d8330. 0 means no table row, which the client renders as value 0.""" if sub is None: return 0 if 0 <= sub <= 3: return 1 if sub == 4: return 2 if sub == 5: return 3 if sub == 6: return 10 if sub == 7: return 5 if sub == 8: return 4 if 9 <= sub <= 11: return 7 if sub in (30, 31, 231, 232, 233, 236) or 145 <= sub <= 150: return 9 if (51 <= sub <= 136) or (201 <= sub <= 220) or (250 <= sub <= 273) \ or (300 <= sub <= 341): return 6 return 0 def discard_value(item): """round_half_up(rating * price / 100), price from fcc_discardcoins. Returns None when the formula does not apply, so callers fall back instead of paying nothing. THE UNRATED-CARD CASE IS NOT COVERED BY THE RECOVERED FORMULA: it was verified 22/22 against club items, all of which were rated players, and `rating * price / 100` collapses to 0 for a staff card carrying no rating. Found by running the whole save through it, where exactly one item (a staff card, cardsubtypeid 8, rating None) came back 0 while the old tier paid 50. Paying 0 for a card the previous code paid for is a regression, so unrated cards fall back. What FUT really pays for staff and consumables is UNKNOWN and worth recovering; the likely answer is the unscaled table price, but that is a guess and is not shipped as one. """ r = item.get("rating") if not r: return None ct = _cardtype(item.get("cardsubtypeid")) r = int(r) lvl = 3 if r >= 75 else 2 if r >= 65 else 1 price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0) if not price: return None # no table row: the client renders 0, we should not n = r * price return n // 100 + (1 if n % 100 >= 50 else 0) # Back-compat snapshots. Identity now lives in fut_account.ACCOUNT so Blaze, LSX # and UTAS cannot drift apart; prefer ACCOUNT. in new code. These are # import-time snapshots and will NOT reflect a later adopt_from_auth(). PERSONA_ID = ACCOUNT.persona_id PERSONA_NAME = ACCOUNT.persona_name _LOCK = threading.Lock() # Starter squad granted on first run (real FIFA17 assetIds; identity resolves # locally in-game). (assetId, rating, position, nation, leagueId, teamid, [6 attrs]) STARTER_PLAYERS = [ (20801, 94, "LW", 38, 53, 243, [90, 93, 82, 91, 33, 80]), # Ronaldo (158023, 93, "RW", 52, 53, 241, [89, 90, 86, 96, 26, 61]), # Messi (200389, 87, "GK", 44, 53, 240, [86, 88, 47, 88, 25, 86]), # Oblak (183907, 90, "CB", 21, 19, 21, [80, 58, 70, 71, 89, 85]), # Boateng (155862, 89, "CB", 45, 53, 243, [77, 62, 72, 74, 87, 84]), # Ramos (197445, 87, "LB", 40, 19, 21, [86, 68, 79, 82, 82, 79]), # Alaba (189332, 86, "LB", 45, 53, 241, [92, 66, 78, 84, 80, 71]), # Alba (182521, 88, "CM", 21, 19, 21, [61, 79, 89, 82, 72, 74]), # Kroos (183277, 88, "LM", 7, 13, 5, [89, 80, 82, 92, 37, 62]), # Hazard (176580, 92, "ST", 60, 53, 241, [83, 90, 79, 87, 42, 80]), # Suarez ] ITEM_ID_BASE = 100000000 # cardsubtypeid 219 is Player Fitness. FUN_1801bfac0 case 5 (consumable category 5) # takes the SQUAD-fitness branch when `(subtype == 0xdc) || FUN_1801a88c0(rec)`, and # FUN_1801a88c0 is exactly `*(int *)(rec + 0x58) == 1` -- rec+0x58 being the rareflag # atom 0x271. So a Player Fitness card sent with rareflag 1 silently RENDERS as a # Squad Fitness card (name FUT_CONSUMABLE_NAME_SQUADTRAINING, artwork 5000011 instead # of 5000010) and has its single-target count at param_5+0x1bc forced to 0. # # rec+0x58 is read TWICE in that 42,813-char render function: unconditionally near the # top into param_5+0x1f0 (the rare/backing flag, every cardtype), and via FUN_1801a88c0 # in category 5 only. So this guard changes two things for subtype 219 -- the card also # stops being drawn as rare -- and that is intended: a Player Fitness card must not be # rare, because rare IS the squad-fitness selector. # # Players are untouched: every existing caller passes 8 positional arguments, so # cardsubtypeid defaults to 0, 0 != 219, and the dict is byte-identical to before. _SQUAD_FITNESS_TRAP = 219 # FUT_TRADEABLE: send untradeable=false so the client's tradeable byte gets set. # # "Place on Transfer List" and "List on Transfer Market" are greyed out on every card, # and BOTH gates are ours. FUN_1801a7260, the TO_TRADE_PILE predicate published by # FUN_18003e370, returns 1 only if the service gate at vtable+0x270 is non-zero AND # item+0x49 is non-zero. The deserializer stores untradeable INVERTED (case 0x361 does # CONCAT11(cVar6 == '\0', ...)), so untradeable:true writes 0 and kills the flag. # # THIS FLAG ALONE IS NOT ENOUGH, and shipping it alone will look like the finding # failed. The other gate is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the # tradingEnabled gate byte. Measured live 2026-08-06 as 0, while friendlySeasons # (0x1fd3a), draftMode (0x1fd3d) and packOpeningAnimation (0x1fd45) all read 1 in the # same walk. tradingEnabled is the only gate byte yet found that is not already 1, and # it is ALREADY in _SETTINGS_KEEP: it has simply never been sent, because # _SETTINGS_MODE defaults to off. So the run needs FUT_SETTINGS=keep beside this. # # Freeze risk: none beyond what we already send. untradeable is atom 0x361 read by the # BOOL primitive FUN_1801c7620, and we already send the key on every card; only the # value changes. The constructor default for +0x49 is 1 (tradeable), so false moves # the field toward the client's own default rather than away from it. # # Side effects, both permissive rather than restrictive: item+0x49 also feeds # FUN_1800bc580, which counts untradeable squad members and publishes UNTRADABLE_COUNT, # which gates squad submission in FUN_1800bba10 (today that takes the # couldNotSubmitSquad branch). TRADEABLE = os.environ.get("FUT_TRADEABLE", "0") == "1" def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00, cardsubtypeid=0, rareflag=1): return _with_discard({ "id": item_id, "resourceId": (version << 24) | asset, "assetId": asset, "cardassetid": asset, "definitionId": (version << 24) | asset, "cardsubtypeid": cardsubtypeid, "itemType": "player", "rareflag": 0 if cardsubtypeid == _SQUAD_FITNESS_TRAP else rareflag, "rating": rating, "preferredPosition": pos, "nation": nation, "teamid": team, "leagueId": league, "playStyle": 250, "attributeList": [{"index": i, "value": v} for i, v in enumerate(attrs)], "itemState": "free", "owners": 1, "untradeable": not TRADEABLE, "contract": 7, "fitness": 99, }) # discardValue is stamped HERE, inside the single item factory, so every path that # builds an item gets it: pack contents, the starter grant, club reads and market # listings alike. Stamping it at one call site would leave the reveal screen and # the club showing different numbers for the same card. SPECIAL_CARD_TYPES = { # name: (rareflag, revision byte, rating/attribute boost, selection weight) # rareflag names come from FIFA 17's ItemRareType enum. Revisions are local, # stable identities; the client resolves the footballer from the low 24 bits. "TOTW": (3, 1, 2, 34), "PURPLE": (4, 2, 3, 7), "TOTY": (5, 3, 6, 3), "RECORD_BREAKER": (6, 4, 5, 2), "TOTS": (11, 5, 5, 7), "OTW": (21, 6, 2, 14), "HALLOWEEN": (22, 7, 3, 8), "MOVEMBER": (23, 8, 3, 8), "SBC": (24, 9, 4, 17), } def choose_special_type(player, rng=None): """Choose a rating-appropriate FIFA 17 promo family for one pool row.""" import random rng = rng or random rating = player[1] eligible = [] for name, spec in SPECIAL_CARD_TYPES.items(): if name in ("TOTY", "RECORD_BREAKER") and rating < 85: continue if name == "TOTS" and rating < 75: continue eligible.append((name, spec[3])) names, weights = zip(*eligible) return rng.choices(names, weights=weights, k=1)[0] def player_item(item_id, player, special=False): """Build a base or named FIFA 17 special revision from a pool row. `special=True` remains supported and chooses a weighted eligible family; callers and tests may also pass an explicit name such as ``"TOTY"``. """ asset, rating, pos, nation, league, team, attrs = player if special: special_name = choose_special_type(player) if special is True else special rareflag, version, boost, _weight = SPECIAL_CARD_TYPES[special_name] rating = min(99, rating + boost) attrs = [min(99, value + boost) for value in attrs] else: rareflag, version = 1, 0 return _item(item_id, asset, rating, pos, nation, league, team, attrs, version=version, rareflag=rareflag) # FUT_DISCARD_SEND: put discardValue (atom 0xd7) on the wire so the CLIENT DISPLAYS # the same number the server pays. # # Measured live 2026-08-06. With FUT_DISCARD_TABLE on, the server correctly paid 600 # for a 75-rated rare gold (9,844,900 -> 9,845,500, exact) while the reveal screen # showed "Quick Sell 0", and "Quick Sell all remaining Items" showed 0 too. So the # figure was right and invisible, and the screen contradicted the wallet. # # The cause is the guard the table work reversed. FUN_18013fe00 stores our # discardValue at item +0x38; at 0x180141025 a `cmp dword [rbp+0x198],0` / `ja` skips # the client's own local computation when that value is NON-ZERO. We seed 0, so the # client runs its own fcc_discardcoins lookup and the price register stays 0. # # CORRECTED 2026-08-06: the two claims that used to sit here -- "that lookup # returns no row for our cards" and "WHY its lookup misses is still UNKNOWN" -- # are both FALSE. The lookup does not miss; real rows exist for both rare values # on (cardtype 6, level, rare). The tile reads a DIFFERENT property, which is why # the wallet and the screen disagreed. Sending a non-zero value still fixes the # display, for the reason below -- it bypasses the local computation entirely -- # but do not carry the "missing row" story forward: it sent one round of work # looking for a table defect that was never there. # # Freeze risk: low and in the safe direction. discardValue is a plain INT read by the # scalar getter 0x1801c79d0. The freezes on this project have all come from feeding an # object or array where a scalar was expected, never the reverse. # # Requires FUT_DISCARD_TABLE, since without the real table this would put the invented # tier on screen and make a wrong number authoritative-looking rather than merely paid. DISCARD_SEND = os.environ.get("FUT_DISCARD_SEND", "0") == "1" and DISCARD_TABLE def _with_discard(it): """Apply the read-path flags to one item. Two things, both of which MUST happen on read and not only at creation: the saved profile holds 246 items minted long before either flag existed, and the club route serves them straight out of the save. Stamping only in _item() left the wire carrying untradeable:true with FUT_TRADEABLE=1 set, which was caught by reading the served JSON rather than by unit-testing the factory. Callers pass a COPY, so the save is never mutated by a read. """ if DISCARD_SEND: # Omit the key entirely when the formula does not apply, rather than sending # 0: a 0 makes the client fall back to its own lookup, and the tile binds our # value anyway, so 0 renders as 0. v = discard_value(it) if v: it["discardValue"] = v if TRADEABLE: it["untradeable"] = False return it def _new_profile(): """First-run grant: opening coins + the starter squad as owned items.""" items = [_item(ITEM_ID_BASE + i + 1, a, r, p, n, lg, tm, at) for i, (a, r, p, n, lg, tm, at) in enumerate(STARTER_PLAYERS)] return { "version": 1, # personaId/personaName/clubName/clubAbbr/established are NOT seeded # here any more -- they belong to fut_account.ACCOUNT. _sync_identity() # mirrors them into the save on every load so existing readers # (utas_server's userInfo, tradepile sellerName) keep working unchanged # and can never disagree with what Blaze/LSX assert. "coins": 15000, "points": 0, "record": {"won": 0, "draw": 0, "loss": 0}, "nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1, "items": items, # owned club items "purchased": [], # unassigned/pending items from opened packs "squads": [], # saved squads (raw squad objects from PUT /squad) "packsOpened": 0, # Owned reward packs are separate from purchased items. Pack 70 is a # one-time migration grant used to bring the retail My Packs flow online. "unopenedPackIds": [70], "unopenedSeeded": True, } class Store: def __init__(self, path=PROFILE_PATH): self.path = path self._p = None def load(self): if self._p is not None: return self._p if os.path.exists(self.path): with open(self.path) as f: self._p = json.load(f) else: self._p = _new_profile() self._sync_identity() self._save() if not self._p.get("unopenedSeeded"): self._p.setdefault("unopenedPackIds", []).append(70) self._p["unopenedSeeded"] = True self._save() self._sync_identity() return self._p def _sync_identity(self): """Mirror ACCOUNT's identity/club into the in-memory save. The save file used to OWN these five keys; they now live in fut_account.json (which is where ACCOUNT migrated them from on first run, so this is a no-op for an existing profile). Mirroring rather than deleting keeps every current reader working without an edit, and makes drift between the save and the wire impossible by construction.""" p = self._p p["personaId"] = ACCOUNT.persona_id p["personaName"] = ACCOUNT.persona_name p["clubName"] = ACCOUNT.club_name p["clubAbbr"] = ACCOUNT.club_abbr p["established"] = ACCOUNT.established # EA/EASFC account-bar state belongs to the same persona as the FUT # save, but remains a distinct balance from FUT coins. p["powLevel"] = ACCOUNT.pow_level p["powExp"] = ACCOUNT.pow_exp p["powExpMax"] = ACCOUNT.pow_exp_max p["powFunds"] = ACCOUNT.pow_funds p["powFundsCap"] = ACCOUNT.pow_funds_cap return p def _save(self): parent = os.path.dirname(self.path) if parent: os.makedirs(parent, exist_ok=True) tmp = self.path + ".tmp" with open(tmp, "w") as f: json.dump(self._p, f, indent=1) os.replace(tmp, self.path) def select_account(self, persona_id): """Switch the single active session to its isolated persistent FUT save.""" with _LOCK: self.path = profile_path_for(persona_id) self._p = None return self.load() # ---- accessors used by utas_server ------------------------------------- def profile(self): return self.load() def ensure_security_question(self): """Persist OpenFUT's account-scoped compatibility state for the FUT gate. FIFA 17 transforms any entered answer before sending it. OpenFUT does not need that value to emulate a retired service, so neither the clear text nor the transformed value is stored. The only durable fact is that this OpenFUT profile has an initialized, verified compatibility record. """ expected = {"version": 1, "verified": True} with _LOCK: p = self.load() if p.get("securityQuestion") != expected: p["securityQuestion"] = dict(expected) self._save() return dict(p["securityQuestion"]) def refresh_identity(self): """Re-mirror ACCOUNT into the save AND persist it. Call this after anything mutates ACCOUNT at runtime (utas_server's club rename, or the /ut/auth persona adoption) so the save cannot lag a session behind the wire. Identity itself is owned by fut_account.json -- this only keeps the save's copy honest.""" with _LOCK: self.load() self._sync_identity() self._save() return self._p def profile_identity(self): """Identity/club as served to the client. Sourced from ACCOUNT, never from the save -- use this instead of profile().get("clubName").""" return { "personaId": ACCOUNT.persona_id, "personaName": ACCOUNT.persona_name, "clubName": ACCOUNT.club_name, "clubAbbr": ACCOUNT.club_abbr, "established": ACCOUNT.established, } def coins(self): return self.load()["coins"] def items(self): # Stamp discardValue on READ as well as on creation. _item() only covers cards # minted from now on, and the save already holds 246 items built before the # flag existed; without this the reveal screen would show real values while # the club showed 0 for everything older. Stamped on the way out and NOT # persisted, so the save stays clean and turning the flag off is a true revert. its = self.load()["items"] return [_with_discard(dict(it)) for it in its] if DISCARD_SEND else its def add_items(self, new_items): with _LOCK: p = self.load() for it in new_items: it.setdefault("id", p["nextItemId"]); p["nextItemId"] += 1 p["items"].append(it) self._save() return new_items def spend(self, amount): with _LOCK: p = self.load() if p["coins"] < amount: return False p["coins"] -= amount self._save() return True def grant_coins(self, amount): with _LOCK: self.load()["coins"] += amount self._save() def quick_sell(self, ids): """Remove cards (from either pile) and credit their discard value. -> (count_sold, coins_credited). discardValue is 0 on our seeded cards, so fall back to a rating-based figure rather than paying nothing.""" def value(it): dv = it.get("discardValue") or 0 if dv: return int(dv) if DISCARD_TABLE: # The real table. Matches what the client displays once # FUT_DISCARD_SEND puts the value on the wire. v = discard_value(it) if v is not None: return v # else: unrated card, formula does not apply, fall through # The invented tier. Wrong for every card, kept only as the live-proven # default until FUT_DISCARD_TABLE has been in front of the game once. r = it.get("rating") or 0 return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50 with _LOCK: p = self.load() want = {i for i in ids if i is not None} total = 0 sold = 0 for pile in ("purchased", "items"): keep = [] for it in p.get(pile, []): if it.get("id") in want: total += value(it) sold += 1 else: keep.append(it) p[pile] = keep if sold: p["coins"] = p.get("coins", 0) + total self._save() return sold, total def set_clientdata(self, key, value): """Persist an opaque client blob (ut/%s/clientdata/). We never interpret it -- the client wrote it, the client reads it back.""" with _LOCK: p = self.load() p.setdefault("clientdata", {})[key] = value self._save() def get_clientdata(self, key): return self.load().get("clientdata", {}).get(key, {}) def record_match(self, result, coins): """Commit a finished match: bump the W/D/L record and credit coins. `result` is "won" | "draw" | "loss". Returns the new (record, coins) so the caller can build FutDestroyMatchServerResponse without a second read -- allCoins must be the balance AFTER crediting, and reading it separately would race another mutation.""" with _LOCK: p = self.load() rec = p.setdefault("record", {"won": 0, "draw": 0, "loss": 0}) if result in rec: rec[result] += 1 p["coins"] = p.get("coins", 0) + max(0, int(coins)) p.setdefault("matchesPlayed", 0) p["matchesPlayed"] += 1 self._save() return dict(rec), p["coins"] def save_squad(self, squad): with _LOCK: p = self.load() sid = squad.get("id", 0) p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad] self._save() def move_items(self, requests): """FutMoveCard: transfer item(s) from the pending/purchased pile into their target pile (FIFO's model), persist, return the moved cards. A purchased card must NOT exist in both the purchased pile and the club, or the client desyncs -> fatal logout. Cards live in profile["purchased"] until moved.""" with _LOCK: p = self.load() pending = p.setdefault("purchased", []) by_id = {it["id"]: it for it in pending} moved = [] for r in requests: it = by_id.get(r.get("id")) if it is None: continue pile = r.get("pile", it.get("pile", "club")) it["pile"] = pile if pile == "club": it["itemState"] = "free" p.setdefault("items", []).append(it) moved.append(it) if moved: moved_ids = {it["id"] for it in moved} p["purchased"] = [x for x in p["purchased"] if x["id"] not in moved_ids] self._save() return moved def purchased(self): # Stamped on read exactly like items(). Leaving this out was a real defect: # the pending pile is the ONE place a quick-sell value is actually read, so # the club showed real numbers while the reveal screen showed 0 for anything # already sitting in the pile. Found by a verification pass, not by testing. """Items still held in the purchased/unassigned pile (returned by GET /purchased/items); they move to the club via FutMoveCard (PUT /item).""" pur = self.load().get("purchased", []) return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur def active_squad(self): sq = self.load()["squads"] return sq[0] if sq else None def unopened_packs(self): """Owned reward-pack template IDs, including repeated grants.""" return list(self.load().get("unopenedPackIds", [])) def consume_unopened_pack(self, pack_id): """Atomically consume one owned instance of a reward pack.""" with _LOCK: p = self.load() owned = p.setdefault("unopenedPackIds", []) try: owned.remove(pack_id) except ValueError: return False self._save() return True def grant_unopened_pack(self, pack_id): """Persist one additional owned reward-pack instance.""" if pack_by_id(pack_id) is None: return False with _LOCK: p = self.load() p.setdefault("unopenedPackIds", []).append(pack_id) self._save() return True def reconstruct_squad(self, squad): """FIFA's updateActiveSquad PUT stores each slot as itemData={id:} (a reference). Re-embed the FULL club item by id so the squad reloads with real players instead of empty slots ('active squad resets').""" by_id = {it["id"]: it for it in self.items()} out = dict(squad) players = [] for pl in squad.get("players", []): iid = (pl.get("itemData") or {}).get("id", 0) if iid and iid in by_id: players.append({**pl, "itemData": by_id[iid]}) else: players.append(pl) out["players"] = players return out # ---- transfer-market listings (user's own sale pile) ------------------- def list_for_sale(self, item_id, start, buynow): with _LOCK: p = self.load() p.setdefault("listings", []) p["listings"] = [l for l in p["listings"] if l.get("itemId") != item_id] tid = 900500000 + p.get("nextListingSeq", 0) p["nextListingSeq"] = p.get("nextListingSeq", 0) + 1 p["listings"].append({"tradeId": tid, "itemId": item_id, "startingBid": start, "buyNowPrice": buynow}) self._save() return tid def listings(self): return self.load().get("listings", []) def remove_listing(self, tid): with _LOCK: p = self.load() p["listings"] = [l for l in p.get("listings", []) if l.get("tradeId") != tid] self._save() def new_item_id(self): with _LOCK: p = self.load(); i = p["nextItemId"]; p["nextItemId"] += 1; self._save() return i def open_pack(self, price, count, gold=True, tiers=None, special_chance=0.0, players_only=False): """Deduct `price` coins, generate `count` player items from the pool, and place them in the PENDING purchased pile (unassigned). They are NOT owned club items until moved there via FutMoveCard (PUT /item). Returns None if not enough coins. `tiers` is a weighted list of tier names, e.g. ["bronze"]*8 + ["silver"]*2, so a bronze pack can actually contain bronzes. The old signature took a single `gold` boolean and split the pool at rating 75, which with the old 18-player pool (all rated 85 to 94) meant EVERY pack, including the bronze one, dealt gold rares. `gold` is still honoured when `tiers` is absent so nothing that calls this the old way changes behaviour. """ import random if not self.spend(price): return None # A real FUT pack is not eleven footballers. It is mostly players with a # couple of consumables and the occasional staff card, which is what # FUT_PACK_MIX reproduces. Kept as a RATIO of the pack size rather than a # fixed number so it scales from a 5-card bronze to an 11-card premium. n_extra = 0 extras = [] if PACK_MIX and not players_only and count >= 5: n_extra = max(1, count // 4) extras = _pack_extras(n_extra, self) n_extra = len(extras) n_players = max(1, count - n_extra) if tiers: # Draw each tier independently but reject duplicate asset IDs inside # one pack. The real pool is large enough that this normally succeeds # on the first attempt; the cap makes malformed tiny test pools safe. picks = [] used_assets = set() for _ in range(n_players): tier_pool = fut_cards.pool_for(random.choice(tiers)) available = [p for p in tier_pool if p[0] not in used_assets] pick = random.choice(available or tier_pool) picks.append(pick) used_assets.add(pick[0]) else: pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL picks = random.sample(pool, min(n_players, len(pool))) while len(picks) < n_players: picks.append(random.choice(pool)) items = [player_item(self.new_item_id(), pick, special=random.random() < special_chance) for pick in picks] items += extras random.shuffle(items) with _LOCK: p = self.load() p.setdefault("purchased", []).extend(items) p["packsOpened"] += 1 self._save() return items def last_pack(self): # Same stamping as purchased(); this is the reveal-screen read path. pur = self.load().get("purchased", []) return [_with_discard(dict(it)) for it in pur] if DISCARD_SEND else pur # FUT_PACK_MIX: put non-player cards in packs. # # Consumables and staff are included because both are LIVE-PROVEN to render (staff on # 2026-08-05 with zero DB Error, consumables the same day with real artwork once # cardassetid was fixed). Club items are NOT included: cardtype 9 has no arm in the # merge, so which cardsubtypeid means "ball" versus "stadium" is still unverified, and # a pack is the worst place to discover that a subtype was wrong -- the card lands in # the save and has to be cleaned out by hand. PACK_MIX = os.environ.get("FUT_PACK_MIX", "1") == "1" def _pack_extras(n, store): """n non-player cards for a pack: mostly consumables, occasionally staff.""" import random out = [] for _ in range(n): want_staff = random.random() < 0.25 it = None if want_staff: try: import fut_staff pool = list(fut_staff.STARTER_MANAGERS) try: import fut_coaches pool += fut_coaches.starter_coaches(fut_coaches.COACH_ID_BASE) except Exception: pass if pool: it = dict(random.choice(pool)) except Exception: it = None if it is None: try: import fut_consumables as fc shelf = fc.starter_consumables(fc.CONSUMABLE_ID_BASE) if shelf: it = dict(random.choice(shelf)) except Exception: it = None if it is None: continue it["id"] = store.new_item_id() # a pack card needs its OWN item id out.append(it) return out # Card pool for packs. Now lives in fut_cards.py (79 players across three rating # tiers, 7 leagues, 20 nations, 18 teams, every outfield position plus GK). The old # 18-entry list below is kept ONLY as the starter-squad source and as the fallback # for callers that still pass the legacy `gold` boolean. PACK_POOL = fut_cards.POOL _LEGACY_POOL = STARTER_PLAYERS + [ (167495, 90, "GK", 27, 19, 22, [86, 88, 52, 88, 22, 88]), # Neuer (192985, 88, "RM", 21, 19, 22, [80, 82, 85, 85, 63, 68]), # De Bruyne (188545, 89, "ST", 37, 16, 240, [77, 88, 75, 82, 42, 82]), # Lewandowski (169193, 87, "CDM",54, 16, 240, [70, 66, 80, 78, 82, 84]), # Alonso(X) (202126, 86, "ST", 18, 13, 5, [79, 84, 74, 82, 45, 79]), # Kane (177003, 88, "CM", 14, 13, 5, [65, 78, 88, 79, 71, 66]), # Modric(X) (190871, 87, "LW", 54, 16, 240, [90, 78, 80, 88, 36, 61]), # Neymar(X) (184941, 85, "CB", 14, 4, 5, [72, 40, 55, 60, 86, 85]), # (X) ] # 3 store packs (price in coins, card count, gold-only). Ids are stable. # `tiers` is the weighted draw for each pack. A bronze pack is mostly bronze with a # chance of silver, a gold pack is mostly gold. Before fut_cards existed the pool had # no silver or bronze players at all, so all three packs were identical in practice. PACK_CATALOG = [ {"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False, "tiers": ["bronze"] * 8 + ["silver"] * 2, "specialChance": 0.005}, {"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True, "tiers": ["gold"] * 6 + ["silver"] * 4, "specialChance": 0.03}, {"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True, "tiers": ["gold"] * 9 + ["silver"] * 1, "specialChance": 0.08}, {"id": 7, "name": "Special Players Pack", "price": 25000, "count": 11, "gold": True, "tiers": ["gold"], "specialChance": 1.0, "playersOnly": True}, {"id": 70, "name": "Reward Special Players Pack", "price": 0, "count": 11, "gold": True, "tiers": ["gold"], "specialChance": 1.0, "playersOnly": True, "ownedOnly": True}, ] def pack_by_id(pid): for p in PACK_CATALOG: if p["id"] == pid: return p return None STORE = Store()