Files
OpenFUT/fifa17-recon/tools/fut_store.py
T
funman300 21a81ad63c fifa17-recon: the real quick-sell table, and the grouping bug is not in our layer
Multi-agent pass over the store subsystem, 11 agents, findings run through three
adversarial verifiers. Full writeup in docs/plan-2026-08-05-store-subsystem.md.

THE REAL DISCARD TABLE IS RECOVERED. quick_sell() paid an invented rating tier
(600/300/150/50) that was wrong for every single card. The real table is
fcc_discardcoins in the client's own game DB, 141 rows keyed (cardtype, level, rare),
read out of the running client and verified 22/22 against live items:

    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

A 94-rated gold rare is 752, not 600. A 76 rare is 608, not 150. A 55 bronze is 17,
not 50.

This also closes a disagreement nobody had noticed: the CLIENT already computes and
displays the correct value locally whenever our discardValue (atom 0xd7) is 0 or
absent. FUN_18013fe00 stores our value at item +0x38 and the guard at 0x180141025
skips the local computation when it is non-zero. So the screen has been showing the
real number while the server paid a made-up one, on every quick sell ever made.

Verified beyond what the report claimed, because a missing table row pays ZERO and
that would be a regression the old flat tier could not produce: across all 236 items
in the live profile, 230 map to cardtype 1 and 6 to cardtype 6, and NOT ONE would pay
0 coins. Table reproduces at 141 rows and the worked example lands exactly.

ZERO WIRE CHANGE, FUT_DISCARD_TABLE default off. Nothing new is sent; only the coin
figure the server credits moves. This is the patch worth defaulting on after one
in-game check, which is simply quick-selling a card and seeing the coins paid match
the value the card was already displaying.

THE GROUPING BUG IS NOT IN CARDSDLL, and the fix ranked first would have wasted a
launch. Live in the running client all three display groups own exactly the right
pack, there is exactly one copy of each pack record in 4 GiB, and nothing we send is
mis-parsed. The parsed model is correct and the Scaleform layer picks the wrong pack
when turning a tile click into a category id. displayGroupAssetId is served as 1/5/6
while the screen's category field reads 3, and group tiles carry a hardcoded
CATEGORY_ID of 0. Confirmed by direct read: ordinal 3, assetId 6, i.e. Premium, while
the last click was Gold.

The heap map that made this possible, all scoped to one pid: display-group vector
control block, 3 elements of 0x108; group record fields at +0x00 sortPriority,
+0x04 displayGroupAssetId, +0x40 a one-element pack vector; inner pack record 0x1a8
with packType at +0x38, ids at +0x70/+0xac, price at +0xa0, quantities at +0xc0..+0xd0.

extPrice SHOULD BE DELETED, not corrected. Both sub-parsers read only
externalPriceId; amount and currency are discarded. Sending the key at all creates an
"mtx" currency row that switches on a real-money price line the client can never fill
offline, which is the literal "or %1s" on every tile.

A WORRY NOBODY HAD RAISED, and I confirmed it from our own logs: the client has sent
packId 6 on every purchase it has ever made, four for four tonight and six for six
across history. We have never observed a successful buy of anything but Premium Gold.

Also settled: FUT_STORE_DISPLAYGROUP=0 is the right resting state, argued from
mechanism rather than from history; FUT_USERINFO=packs stays off because the
unopened-pack counter is client-mutable and the flag ladder silently drops squadList;
POST /user is a latent hard freeze that has never fired because the client never
issues that POST.

Honest coverage: the ActionScript layer is unread by everyone and every remaining
store mystery lives there.

Live: 439 contract checks pass, market suite passes, both flags off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 07:43:51 -07:00

583 lines
23 KiB
Python

"""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_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
# ---- 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."""
ct = _cardtype(item.get("cardsubtypeid"))
r = int(item.get("rating") or 0)
lvl = 3 if r >= 75 else 2 if r >= 65 else 1
price = _DP.get((ct, lvl, int(item.get("rareflag") or 0)), 0)
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.<field> 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
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00,
cardsubtypeid=0, rareflag=1):
return {
"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": True,
"contract": 7,
"fitness": 99,
}
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,
}
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()
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
return p
def _save(self):
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump(self._p, f, indent=1)
os.replace(tmp, self.path)
# ---- accessors used by utas_server -------------------------------------
def profile(self):
return self.load()
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):
return self.load()["items"]
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 already displays, so the
# coins paid and the coins shown finally agree.
return discard_value(it)
# 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/<key>). 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):
"""Items still held in the purchased/unassigned pile (returned by
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
return self.load().get("purchased", [])
def active_squad(self):
sq = self.load()["squads"]
return sq[0] if sq else None
def reconstruct_squad(self, squad):
"""FIFA's updateActiveSquad PUT stores each slot as itemData={id:<clubItemId>}
(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):
"""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 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:
picks = [random.choice(fut_cards.pool_for(random.choice(tiers)))
for _ in range(n_players)]
else:
pool = [p for p in PACK_POOL if (p[1] >= 75) == gold] or PACK_POOL
picks = [random.choice(pool) for _ in range(n_players)]
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
for (a, r, p, n, lg, tm, at) 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):
return self.load().get("purchased", [])
# 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},
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True,
"tiers": ["gold"] * 6 + ["silver"] * 4},
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True,
"tiers": ["gold"] * 9 + ["silver"] * 1},
]
def pack_by_id(pid):
for p in PACK_CATALOG:
if p["id"] == pid:
return p
return None
STORE = Store()