70a64e3709
Freeze the running offline FUT backend into version control as fifa17-recon/docker/fifa17-python/ - declarative and rebuildable from a fresh checkout: * OPENFUT_BIND / OPENFUT_ADVERTISE client/server split in the responders (lsx, blaze, roster, utas, pow) + entrypoint.sh; OPENFUT_ADVERTISE is required for remote mode (compose and entrypoint fail without it) * docker-compose.yml reproducing the frozen baseline container exactly (env, ports incl. the 8085->8080 POW-content remap, /state bind, restart) * .env.example / .env for site config - the LAN IP is never hardcoded in source * tools/ + data/ staged from openfut-fut-backend:python-baseline-2026-08-10, verified byte-identical to the running container at freeze time * client_arm.sh (the 105 client-side arming counterpart) * Dockerfile bakes /app/SHA256SUMS.txt so any image is self-identifying * docs/BASELINE-python-2026-08-10.md: frozen image/container/hash record, restore instructions and rebuild-equivalence procedure Secrets (redir key/cert, .env) and runtime state (docker/state) stay gitignored. The live container is untouched pending the .105 launcher audit.
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()
|