a3fd51692f
Ships the club-item subtype correction and the tradeable plumbing, and records two
refutations of claims made earlier in the same session. Nothing here is a working fix
for trading; the honest state is that the gate is still closed and we now know more
about why.
REFUTED 1: "the Blaze client-config store opens the trading gate". It does not, and the
flag is inert. IS_TRADING_ENABLED is an OUTPUT NAME. FUN_18006cc60 is a publisher: at
0x18006ccc6 it calls [rax+0x270] to READ gate byte 0x1fd2e, then lea rdx,[
IS_TRADING_ENABLED] and hands the value out under that name. The only rip-relative
reference to the literal 0x1801fc118 in all of .text is that lea; there is no comparison
against it anywhere, so no client-config key of that name can be read as an input. That
also undermines the IS_* store keys shipped beside it: their apparent success was never
actually attributed to them.
REFUTED 2: "the gate byte flipped to 1". It reads 0. It was measured as 1 shortly after
CardsDLL mapped and that was over-claimed as a success; a thorough re-measurement read 0
on the SAME pid and model pointer, and a fresh session reads 0 with an unambiguous raw
dump (model+0x1fd18.. = 01000000 00000000 00000000 00000000 3c000000 01 01 00 01, the 00
being 0x1fd2e). Either the first read was transient or something clears it after login.
The only writer is FUN_18011dc50 at 0x18011dc91, so a 0 means something RAN and wrote it.
AND THE "/settings IS DEAD" CLAIM FALLS TOO. FUN_18011dc50 is not unreachable: it is a
VIRTUAL method at model vtable slot +0x988 (absolute pointer 0x18021cc28). A direct-call
search found no callers because Ghidra does not resolve virtual calls, which is the same
dispatch-form trap that has now produced seven wrong verdicts here. The real chain is
settings response -> FUN_180174630 -> FUN_18013c6d0 (deser)
-> completion callback FUN_180173e00 -> vt+0x988 and vt+0x998 -> gate bytes
and FUN_180173e00 bails before applying anything unless the int at response+0x1c is
zero. Which atom writes +0x1c is unknown and is the thing worth chasing.
The measurement behind that claim also had a gap: it checked +0x1fd14, +0x1fd4c and
+0x1fd54 for the maximumTradePileSize=77 probe but NOT +0x1fd1c, which is the actual
TRADE_PILE_SIZE (read via vt+0xa58 = FUN_18011bf30). So the probe never tested the field
it needed to. Serving 77 and reading +0x1fd1c is the clean falsifier and is still open.
Recovered and worth keeping: an authoritative slot-to-name table from the publisher.
vt+0x270 IS_TRADING_ENABLED -> +0x1fd2e vt+0x2b0 IS_FRIENDLY_SEASON_ENABLED -> +0x1fd3a
vt+0x2b8 IS_TOURNAMENT_QUIT_ENABLED -> +0x1fd3b vt+0x2c0 IS_PROCESSING_STATE_ENABLED -> +0x1fd3c
vt+0x2c8 IS_DRAFT_MODE_ENABLED -> +0x1fd3d vt+0x2d8 IS_STORY_MODE_REWARD_ENABLED -> +0x1fd3f
vt+0x2f0 IS_RETURNING_USER_REWARDS_SCREEN -> +0x1fd40 vt+0xa58 TRADE_PILE_SIZE -> +0x1fd1c
That also locates the red TRANSFER LIST 0/0: it is +0x1fd1c, currently 0.
WHAT IS ACTUALLY SHIPPED HERE, all default off:
* FUT_TRADEABLE sends untradeable=false. Verified landing at item+0x49 (stored
INVERTED by case 0x361) on a live club record. Applied on every READ path, not only
in _item(), because the save holds 246 items minted before the flag existed and the
club route serves them straight from the save. That gap was caught by reading the
served JSON, not by unit-testing the factory.
* FUT_TRADING adds tradingEnabled and IS_TRADING_ENABLED to the Blaze config. Kept
only as a record of the refutation, with the reasoning inline so nobody retries it.
* fut_clubitems FAMILIES subtypes corrected: kit 9, stadium 10, badge 11 (cardtype 7,
not 9), ball 30, league logo 31. Every previous value sat in the 0x91..0x96 TROPHY
block. probe_shelf's candidate set lacked 9, 10 and 11, so the probe route the docs
preferred could never have answered this for three of five families.
* Club kits and badges now carry teamid, reintroduced ALONE after the 2026-08-05 crash
(which was never bisected; value is the established suspect and that response also
carried 30 items across five wrong subtypes). itemType dropped: it was unobserved and
never copied into the record.
Live: 439 contract checks, 414 card-family checks, market suite, all pass. The transfer
market still refuses with zero requests and the menu entries are still greyed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
696 lines
30 KiB
Python
696 lines
30 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.
|
|
|
|
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.
|
|
|
|
|
|
# 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,
|
|
}
|
|
|
|
|
|
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):
|
|
# 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 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):
|
|
# 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},
|
|
{"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()
|