e578443d73
Card-subsystem pass, 11 agents plus three adversarial verifiers. Full writeup in docs/plan-2026-08-06-card-subsystem.md. Two of the results below correct things I committed earlier today. THE GREYED-OUT TRANSFER OPTIONS ARE EXPLAINED. "Place on Transfer List" and "List on Transfer Market" have been disabled in the reveal screen and nobody knew why. TO_TRADE_PILE (FUN_1801a7260) requires BOTH item+0x49 tradeable AND a service gate at vtable slot +0x270. That slot is `movzx eax, byte [rcx+0x1fd2e]; ret`, and 0x1fd2e is the tradingEnabled gate byte. Read live and reproduced independently: slot +0x2b0 friendlySeasons disp 0x1fd3a VALUE=1 slot +0x2c8 draftMode disp 0x1fd3d VALUE=1 slot +0x2e0 packOpeningAnim disp 0x1fd45 VALUE=1 slot +0x270 tradingEnabled disp 0x1fd2e VALUE=0 tradingEnabled is the FIRST gate byte found that is not 1. This partly rehabilitates the settings work from this morning: that plan died because every gate it targeted already read 1, and the conclusion drawn was that the settings array does not matter. It does. It matters for a flag nobody was looking at, and tradingEnabled is ALREADY in _SETTINGS_KEEP, plumbed and never sent because _SETTINGS_MODE defaults to off. So the fix is two things, not one: FUT_SETTINGS=keep AND untradeable false. Shipping only the boolean would look like the finding failed. THE DISCARD "MISS" NEVER EXISTED, which correctse3092ca. fcc_discardcoins is resident and complete, the client lookup runs and is correct, and it lands at item+0x3c. The tile simply binds +0x38, which is OUR value, and nothing falls back to +0x3c. So the client was not failing a lookup; it was faithfully displaying the 0 we sent. Same observable, completely different mechanism, and the version ine3092cais wrong. FUT_DISCARD_SEND remains exactly the right fix, now for the right reason. WHAT FUT PAYS FOR STAFF IS NO LONGER UNKNOWN. Same formula, but the rating input is the table `value` column: gkcoachcards 9000081 value 66 gives 36, and the client's own +0x3c reads 36. That closes the gap I flagged ine3092caas not-guessed. CLUB ITEM SUBTYPES, the standing unknown in CARD_SYSTEM.md, are settled: kit 9, stadium 10, badge 11 are cardtype 7 (not 9), ball 30, league logo 31 by elimination. All five constants in fut_clubitems.FAMILIES are wrong and all five currently sit in the TROPHY block 0x91..0x96. Note the probe route the doc preferred could never have answered this: probe_shelf()'s candidate set lacks 9, 10 and 11, so it would have spent a launch and returned nothing for three of five families. THE CARD MODEL FIELD MAP now exists, 28 rows, every field we send with the byte it lands on and whether the client keeps it. Built by diffing what we serve against the parsed records in the live heap (stride 0x180, anchored by a satellite back-pointer rather than by assuming the +0x38 offset). Corrections that change what we serve: +0x54 is the discard LEVEL not itemType, +0x49 is untradeable INVERTED, +0x5c is itemState, definitionId is not an atom at all. A HIGH-CONFIDENCE ABSENCE CLAIM WAS REFUTED IN VERIFICATION: playStyle IS stored, at +0x88. Its controls were raw scalars while playStyle is a DECODED scalar, so the control was the wrong FORM. That is a new variant of the absence trap, which has now cost six wrong verdicts, and it is recorded in the doc. FIX TO MY OWN PATCH frome3092ca: purchased() and last_pack() lacked the _with_discard wrapper that items() had, so the pending pile, which is the one place a quick-sell value is actually read, served unstamped cards. Found by verification, not testing. All three read paths now stamp. Correcting an overstatement ine3092ca: "turning the flag off is a true revert" holds for the read paths, which copy, but NOT for cards minted while armed, because _item() stamps at creation and those persist (9 items currently). Kept deliberately: the pack reveal serves itemList straight from open_pack(), not through purchased(), so removing creation-stamping would leave the screen that matters unstamped. Persisted values are correct and self-heal, since every read recomputes and overwrites. Nothing here has been on screen. Six patches are proposed in the doc as pasteable text, env-flagged, defaulting off, none applied. Live: 439 contract checks, 414 card-family checks, market suite, all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
656 lines
27 KiB
Python
656 lines
27 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
|
|
|
|
|
|
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": True,
|
|
"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):
|
|
"""Stamp discardValue when armed. Omits the key entirely when the formula does
|
|
not apply, rather than sending 0, because a 0 makes the client fall back to its
|
|
own lookup, which is exactly the broken path we are routing around."""
|
|
if DISCARD_SEND:
|
|
v = discard_value(it)
|
|
if v:
|
|
it["discardValue"] = v
|
|
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()
|