83539e33ec
The earlier reconcile committed the local working-tree versions of these files, which are OLDER than the deployed backend. The running container (C) is byte-identical to docker/fifa17-python/tools (B) and is a strict superset: it adds profile_path_for/select_account/ensure_security_question (fut_store), safe_header_for_log/safe_request_path/security_question_route (utas_server), account_sync_route/_match_call/match_ready_body, plus POW balance fields and match lifecycle support, with zero unique local functions lost. Reconciled tree is now a strict superset of B with every shared file byte-identical; verified via md5 map (0 missing, 0 differing).
844 lines
36 KiB
Python
844 lines
36 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_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.<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
|
|
|
|
|
|
# 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, that lookup returns no row for our
|
|
# cards, the price register stays 0, and it renders 0. WHY its lookup misses is still
|
|
# UNKNOWN and worth knowing, but it does not have to be answered to fix the display:
|
|
# sending a non-zero value bypasses the lookup entirely and the client uses ours.
|
|
#
|
|
# 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/<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):
|
|
# 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:<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, 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()
|