3b58b29094
Correcting an overstatement first: I said every card family works. Balls, stadia,
badges and kits do not, and this is the start of that, not the finish.
CLUB ITEMS (FUT_CLUBITEMS=1, default off). tools/fut_clubitems.py builds shelves from
the game's own tables: fcc_balls 42, fcc_stadium 78, fcc_badgecards 656,
fcc_kitcards 1482, fcc_leaguelogos 44, each with its real carddbid AND its real
cardassetid. Counts are wired into the club stat set, replacing the honest zeros
rather than appending a second row per id (the deserializer does
store[ctx][statId] = value, so two rows for one id is a coin toss).
Counts FIRST and on purpose. The consumables round proved the count gates the fetch:
the client does not ask for an item list until club/stats reports a non-zero count,
and the CLUB tab reads 0x1e balls, 0x28 kits, 0x14 stadia. Arming the counters is what
makes the client name the item route, which is the one thing static reading has never
produced for this family -- cardtype 9 has NO arm in the merge, so nothing about it is
discoverable from the card DB.
What is deliberately NOT guessed: which cardsubtypeid means ball versus stadium. The
eight values that reach cardtype 9 are {30,31,145..150} and the assignment appears in
none of the 149 dumped tables. probe_shelf() serves one item per candidate so the
screen can say which is which, rather than shipping a guess into someone's club.
PACKS (FUT_PACK_MIX=1, default on). A pack is no longer eleven footballers: roughly a
quarter of each pack is now consumables and occasionally staff, as a ratio so it
scales from a 5-card bronze to an 11-card premium. Club items are excluded until their
subtypes are verified -- a pack is the worst place to discover a wrong subtype,
because the card lands in the save and has to be cleaned out by hand.
Also fixes the art id AT THE SOURCE. fut_consumables now sets cardassetid from the
fcc_ tables, so a pack-granted consumable renders correctly too. The earlier fix only
corrected the copy served by the club route, which meant packs would have dealt cards
with the green NOT FOUND box again.
Pack tests run against a COPY of the profile, after an earlier test persisted 15 cards
into the live save and had to be undone by hand.
439 + 414 checks green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
488 lines
20 KiB
Python
488 lines
20 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"))
|
|
|
|
# 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)
|
|
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()
|