fifa17-recon: persistent FUT profile + starter pack
Add fut_store.py: a JSON-backed profile store (coins, owned club items, saved squads, record). First run grants a starter pack -- 15000 coins + a 10-player starter club (real assetIds; identity resolves locally in-game per docs/CARD_SYSTEM.md). Wire utas_server to the store: /user/credits -> persisted coins, /club -> persisted owned items, PUT /squad -> persists the squad the user builds so it survives relaunches. Profile save file is gitignored. Foundation for pack-opening (store/purchasegroup + transaction) next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PN5bmpDVQR1aXgefyWAt7o
This commit is contained in:
@@ -9,3 +9,4 @@
|
|||||||
*.log
|
*.log
|
||||||
__pycache__/
|
__pycache__/
|
||||||
captures/
|
captures/
|
||||||
|
tools/fifa17_profile.json
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"""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, threading
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))
|
||||||
|
|
||||||
|
PERSONA_ID = 33068179
|
||||||
|
PERSONA_NAME = "CAGE"
|
||||||
|
_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
|
||||||
|
|
||||||
|
|
||||||
|
def _item(item_id, asset, rating, pos, nation, league, team, attrs, version=0x00):
|
||||||
|
return {
|
||||||
|
"id": item_id,
|
||||||
|
"resourceId": (version << 24) | asset,
|
||||||
|
"assetId": asset,
|
||||||
|
"cardassetid": asset,
|
||||||
|
"definitionId": (version << 24) | asset,
|
||||||
|
"cardsubtypeid": 0,
|
||||||
|
"itemType": "player",
|
||||||
|
"rareflag": 1,
|
||||||
|
"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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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": PERSONA_ID,
|
||||||
|
"personaName": PERSONA_NAME,
|
||||||
|
"clubName": "OpenFUT",
|
||||||
|
"clubAbbr": "OFC",
|
||||||
|
"established": "2026",
|
||||||
|
"coins": 15000,
|
||||||
|
"points": 0,
|
||||||
|
"record": {"won": 0, "draw": 0, "loss": 0},
|
||||||
|
"nextItemId": ITEM_ID_BASE + len(STARTER_PLAYERS) + 1,
|
||||||
|
"items": items, # owned club items
|
||||||
|
"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._save()
|
||||||
|
return self._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 coins(self):
|
||||||
|
return self.load()["coins"]
|
||||||
|
|
||||||
|
def items(self):
|
||||||
|
return self.load()["items"]
|
||||||
|
|
||||||
|
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 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 active_squad(self):
|
||||||
|
sq = self.load()["squads"]
|
||||||
|
return sq[0] if sq else None
|
||||||
|
|
||||||
|
def new_item_id(self):
|
||||||
|
with _LOCK:
|
||||||
|
p = self.load(); i = p["nextItemId"]; p["nextItemId"] += 1; self._save()
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
STORE = Store()
|
||||||
@@ -15,6 +15,7 @@ import datetime, json, os, re, sys, http.server
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room)
|
from fut_seed import CLUB, SQUAD, USER_LIST # forged starter squad (clean-room)
|
||||||
|
from fut_store import STORE # persistent profile (coins/club/squads)
|
||||||
|
|
||||||
ADDR = ("127.0.0.1", 8099)
|
ADDR = ("127.0.0.1", 8099)
|
||||||
LOG = "/tmp/utas_server.log"
|
LOG = "/tmp/utas_server.log"
|
||||||
@@ -159,7 +160,7 @@ ROUTES = [
|
|||||||
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
|
(re.compile(G + r"/phishing/trusteddevice"), lambda m, h: (200, {"trusted": True})),
|
||||||
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
|
(re.compile(G + r"/phishing/validate"), lambda m, h: (200, {"token": "OPENFUT-TRUST-0000000000000000"})),
|
||||||
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
(re.compile(G + r"/phishing/question"), lambda m, h: (200, {"question": 0, "answer": "", "attempts": 5})),
|
||||||
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": 15000})),
|
(re.compile(G + r"/user/credits"), lambda m, h: (200, {"credits": STORE.coins()})),
|
||||||
# ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ----
|
# ---- club/squad routes reverted to known-good {} stubs (2026-08-01) ----
|
||||||
# The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard
|
# The forged squad in MASSINFO/SQUAD/CLUB HANGS CardsDLL's deserializer (hard
|
||||||
# freeze at boot). Re-enable only after the exact shape is reversed. The forged
|
# freeze at boot). Re-enable only after the exact shape is reversed. The forged
|
||||||
@@ -173,7 +174,7 @@ ROUTES = [
|
|||||||
# STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}.
|
# STEP 1 (wf_0bc80ab3): zero-resolve squad in MASSINFO; club stays {}.
|
||||||
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)),
|
(re.compile(G + r"/userMassInfo"), lambda m, h: (200, MASSINFO)),
|
||||||
(re.compile(G + r"/season"), lambda m, h: (200, {})),
|
(re.compile(G + r"/season"), lambda m, h: (200, {})),
|
||||||
(re.compile(G + r"/club"), lambda m, h: (200, CLUB)),
|
(re.compile(G + r"/club"), lambda m, h: (200, {"itemData": STORE.items()})),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -187,9 +188,19 @@ def user_route(h):
|
|||||||
|
|
||||||
|
|
||||||
def squad_route(h):
|
def squad_route(h):
|
||||||
# GET = LoadActiveSquad, PUT = updateActiveSquad. Always echo the full canonical
|
# GET = LoadActiveSquad, PUT = updateActiveSquad. Persist the squad the user
|
||||||
# squad (never {} — an empty body resets the client's 23 slots, 0x18013d1f0).
|
# builds so it survives relaunches. Never return {} (empty body resets the 23
|
||||||
return 200, SQUAD
|
# slots, 0x18013d1f0).
|
||||||
|
if h.command == "PUT":
|
||||||
|
try:
|
||||||
|
sq = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else None
|
||||||
|
except Exception:
|
||||||
|
sq = None
|
||||||
|
if isinstance(sq, dict) and sq.get("players"):
|
||||||
|
STORE.save_squad(sq)
|
||||||
|
return 200, sq
|
||||||
|
saved = STORE.active_squad()
|
||||||
|
return 200, (saved if saved else SQUAD)
|
||||||
|
|
||||||
|
|
||||||
class H(http.server.BaseHTTPRequestHandler):
|
class H(http.server.BaseHTTPRequestHandler):
|
||||||
@@ -198,6 +209,7 @@ class H(http.server.BaseHTTPRequestHandler):
|
|||||||
def _handle(self):
|
def _handle(self):
|
||||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||||
body = self.rfile.read(n) if n else b""
|
body = self.rfile.read(n) if n else b""
|
||||||
|
self._body = body # route fns (squad PUT) read this
|
||||||
log("%s %s" % (self.command, self.path))
|
log("%s %s" % (self.command, self.path))
|
||||||
for k, v in self.headers.items():
|
for k, v in self.headers.items():
|
||||||
log(" %s: %s" % (k, v))
|
log(" %s: %s" % (k, v))
|
||||||
|
|||||||
Reference in New Issue
Block a user