fifa17-recon: match rewards, POW online layer, account backend, quick sell
Second session. FUT core loop, the EASFC/POW online layer, a central account
backend, and a lot of corrections. Everything risky is behind an env flag with
the default set to whatever was live-proven.
WORKING END TO END (live-verified this session):
* match loop -- POST/PUT/POST/DELETE ut/%s/match, rewards via FutDestroyMatch
(0x180121b60). Play a match, get coins, W/D/L updates.
* packs -- buy, cards land in the club, session survives (FUT_PACK_AUTOCLUB=1)
* quick sell -- POST ut/delete/%s/item was UNMAPPED and paid NOTHING; six cards
were destroyed for 0 coins. Now credits discardValue.
* POW/EASFC online -- the "EA FC servers unreachable" banner is powdll's layer,
a THIRD http api on :8094 nobody had served. Redirect needs no root: powdll
FUN_18005a460 reads FIFA_POW_URL from the same client-config store as
ROSTERUPDATE_URL. FUT_POW=1.
* account backend -- fut_account.py replaces 7 hardcoded copies of the persona
across 5 files; club/persona/online-profile editable via CLI.
CORRECTIONS TO ENDPOINT_MAP (all re-extracted from the deserializers):
* FutStoreGetPackTypes: id/packType/isPremium/quantity/saleType/purchaseLimit/
purchaseCount are NOT skipped no-ops -- all are parsed. extPrice inner objects
take externalPriceId(0x11a), not amount/currency.
* FutMoveCard 0x180128600 has NO skip handler (FUN_180135ff0 appears zero times,
unique among FUT deserializers) and parses only itemData -> dreamSquads.
* class -> deserializer resolution: the name literal is preceded by a 4-BYTE
HEADER and the factory LEA points at the header, so look up name_addr - 4.
Six attempts failed on this; now ghidra_env.class_deser(). Unlocked 11 SBC/
Draft schemas.
* live-only endpoints the request table never lists: ut/%s/squad/list,
ut/%s/user/club, ut/%s/club/stats/*, ut/%s/clientdata/<key>. The template
table is a floor, not a ceiling -- the log is the only ground truth.
* 163 RS4 call names exist; we served 17. All now served.
FIXED: club/stats/* was answering with the entire 28-item club inventory on every
poll (it fell through to the generic /club route).
UNSOLVED: the pack reveal's "Send to Club" (PUT ut/%s/item) kills the FUT session
whatever we answer -- {} included -- while its sibling quick-sell endpoint accepts
a bare {}. Seven hypotheses eliminated by live test, documented in
REBUILD_RESEARCH.md S14c so none get re-walked. FUT_PACK_AUTOCLUB routes around it.
Also unfixed: store tiles render "unknown" (displayGroup is parsed RECURSIVELY by
the same element parser; sending it FROZE the store, so FUT_STORE_GROUPS=1 is
default off).
Tests: test_fut_contract.py 380 (live, read-only) + test_match_rewards.py 51 (pure).
Note: fut_store.py carries some pre-existing uncommitted changes from before this
session (pack catalogue ids, pending-pile behaviour) that could not be separated
from this session's additions in the same file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VUT92pz6RWKih9dSr8ZpxW
This commit is contained in:
+157
-17
@@ -10,13 +10,19 @@ 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, threading
|
||||
import json, os, sys, threading
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
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"))
|
||||
|
||||
PERSONA_ID = 33068179
|
||||
PERSONA_NAME = "CAGE"
|
||||
# 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
|
||||
@@ -68,16 +74,17 @@ def _new_profile():
|
||||
for i, (a, r, p, n, lg, tm, at) in enumerate(STARTER_PLAYERS)]
|
||||
return {
|
||||
"version": 1,
|
||||
"personaId": PERSONA_ID,
|
||||
"personaName": PERSONA_NAME,
|
||||
"clubName": "OpenFUT",
|
||||
"clubAbbr": "OFC",
|
||||
"established": "2026",
|
||||
# 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,
|
||||
}
|
||||
@@ -96,9 +103,27 @@ class Store:
|
||||
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:
|
||||
@@ -109,6 +134,30 @@ class Store:
|
||||
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"]
|
||||
|
||||
@@ -138,6 +187,64 @@ class Store:
|
||||
self.load()["coins"] += amount
|
||||
self._save()
|
||||
|
||||
def quick_sell(self, ids):
|
||||
"""Remove cards (from either pile) and credit their discard value.
|
||||
-> (count_sold, coins_credited). discardValue is 0 on our seeded cards, so
|
||||
fall back to a rating-based figure rather than paying nothing."""
|
||||
def value(it):
|
||||
dv = it.get("discardValue") or 0
|
||||
if dv:
|
||||
return int(dv)
|
||||
r = it.get("rating") or 0
|
||||
return 600 if r >= 85 else 300 if r >= 80 else 150 if r >= 75 else 50
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
want = {i for i in ids if i is not None}
|
||||
total = 0
|
||||
sold = 0
|
||||
for pile in ("purchased", "items"):
|
||||
keep = []
|
||||
for it in p.get(pile, []):
|
||||
if it.get("id") in want:
|
||||
total += value(it)
|
||||
sold += 1
|
||||
else:
|
||||
keep.append(it)
|
||||
p[pile] = keep
|
||||
if sold:
|
||||
p["coins"] = p.get("coins", 0) + total
|
||||
self._save()
|
||||
return sold, total
|
||||
|
||||
def set_clientdata(self, key, value):
|
||||
"""Persist an opaque client blob (ut/%s/clientdata/<key>). We never
|
||||
interpret it -- the client wrote it, the client reads it back."""
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
p.setdefault("clientdata", {})[key] = value
|
||||
self._save()
|
||||
|
||||
def get_clientdata(self, key):
|
||||
return self.load().get("clientdata", {}).get(key, {})
|
||||
|
||||
def record_match(self, result, coins):
|
||||
"""Commit a finished match: bump the W/D/L record and credit coins.
|
||||
|
||||
`result` is "won" | "draw" | "loss". Returns the new (record, coins) so the
|
||||
caller can build FutDestroyMatchServerResponse without a second read --
|
||||
allCoins must be the balance AFTER crediting, and reading it separately
|
||||
would race another mutation."""
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
rec = p.setdefault("record", {"won": 0, "draw": 0, "loss": 0})
|
||||
if result in rec:
|
||||
rec[result] += 1
|
||||
p["coins"] = p.get("coins", 0) + max(0, int(coins))
|
||||
p.setdefault("matchesPlayed", 0)
|
||||
p["matchesPlayed"] += 1
|
||||
self._save()
|
||||
return dict(rec), p["coins"]
|
||||
|
||||
def save_squad(self, squad):
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
@@ -145,6 +252,37 @@ class Store:
|
||||
p["squads"] = [s for s in p["squads"] if s.get("id") != sid] + [squad]
|
||||
self._save()
|
||||
|
||||
def move_items(self, requests):
|
||||
"""FutMoveCard: transfer item(s) from the pending/purchased pile into their
|
||||
target pile (FIFO's model), persist, return the moved cards. A purchased
|
||||
card must NOT exist in both the purchased pile and the club, or the client
|
||||
desyncs -> fatal logout. Cards live in profile["purchased"] until moved."""
|
||||
with _LOCK:
|
||||
p = self.load()
|
||||
pending = p.setdefault("purchased", [])
|
||||
by_id = {it["id"]: it for it in pending}
|
||||
moved = []
|
||||
for r in requests:
|
||||
it = by_id.get(r.get("id"))
|
||||
if it is None:
|
||||
continue
|
||||
pile = r.get("pile", it.get("pile", "club"))
|
||||
it["pile"] = pile
|
||||
if pile == "club":
|
||||
it["itemState"] = "free"
|
||||
p.setdefault("items", []).append(it)
|
||||
moved.append(it)
|
||||
if moved:
|
||||
moved_ids = {it["id"] for it in moved}
|
||||
p["purchased"] = [x for x in p["purchased"] if x["id"] not in moved_ids]
|
||||
self._save()
|
||||
return moved
|
||||
|
||||
def purchased(self):
|
||||
"""Items still held in the purchased/unassigned pile (returned by
|
||||
GET /purchased/items); they move to the club via FutMoveCard (PUT /item)."""
|
||||
return self.load().get("purchased", [])
|
||||
|
||||
def active_squad(self):
|
||||
sq = self.load()["squads"]
|
||||
return sq[0] if sq else None
|
||||
@@ -194,8 +332,10 @@ class Store:
|
||||
|
||||
|
||||
def open_pack(self, price, count, gold=True):
|
||||
"""Deduct `price` coins, generate `count` player items from the pool, add
|
||||
them to the club, return them. Returns None if not enough coins."""
|
||||
"""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."""
|
||||
import random
|
||||
if not self.spend(price):
|
||||
return None
|
||||
@@ -203,15 +343,15 @@ class Store:
|
||||
picks = [random.choice(pool) for _ in range(count)]
|
||||
items = [_item(self.new_item_id(), a, r, p, n, lg, tm, at)
|
||||
for (a, r, p, n, lg, tm, at) in picks]
|
||||
self.add_items(items)
|
||||
with _LOCK:
|
||||
self.load()["packsOpened"] += 1
|
||||
self._last_pack = items
|
||||
p = self.load()
|
||||
p.setdefault("purchased", []).extend(items)
|
||||
p["packsOpened"] += 1
|
||||
self._save()
|
||||
return items
|
||||
|
||||
def last_pack(self):
|
||||
return getattr(self, "_last_pack", [])
|
||||
return self.load().get("purchased", [])
|
||||
|
||||
|
||||
# Card pool for packs. TODO: replace with a full dbdata.dll extract (~18k players);
|
||||
@@ -229,9 +369,9 @@ PACK_POOL = STARTER_PLAYERS + [
|
||||
|
||||
# 3 store packs (price in coins, card count, gold-only). Ids are stable.
|
||||
PACK_CATALOG = [
|
||||
{"id": 101, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False},
|
||||
{"id": 102, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True},
|
||||
{"id": 103, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True},
|
||||
{"id": 1, "name": "Bronze Pack", "price": 400, "count": 5, "gold": False},
|
||||
{"id": 5, "name": "Gold Pack", "price": 5000, "count": 7, "gold": True},
|
||||
{"id": 6, "name": "Premium Gold", "price": 15000, "count": 11, "gold": True},
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user